diff --git a/.travis.yml b/.travis.yml index c75db89dc54..2d7f96a8b8a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,8 +15,7 @@ cache: - $HOME/.cache/electron-builder before_script: - - npm install -g @angular/cli - - npm i npm@latest -g + - npm install npm@latest -g - gulp script: diff --git a/Dockerfile b/Dockerfile index d7a27002cfd..991bd3f7f19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,17 +11,17 @@ EXPOSE 35729 # Port 53703 is the Chrome dev logger port. EXPOSE 53703 -# MoodleMobile uses Ionic and Gulp. -RUN npm i -g ionic gulp && rm -rf /root/.npm - WORKDIR /app COPY . /app -# Install npm libraries and run gulp to initialize the project. -RUN npm install && gulp && rm -rf /root/.npm +# Install npm libraries. +RUN npm install && rm -rf /root/.npm + +# Run gulp before starting. +RUN npx gulp # Provide a Healthcheck command for easier use in CI. HEALTHCHECK --interval=10s --timeout=3s --start-period=30s CMD curl -f http://localhost:8100 || exit 1 -CMD ["ionic", "serve", "-b"] +CMD ["npm", "run", "ionic:serve"] diff --git a/config.xml b/config.xml index 186608e98c8..1ab1ad371ee 100644 --- a/config.xml +++ b/config.xml @@ -1,5 +1,5 @@ - + Moodle Moodle official app Moodle Mobile team @@ -23,7 +23,7 @@ - + @@ -113,30 +113,30 @@ - - + + - + - + - - + + - + - - - - - - + + + + + + @@ -147,6 +147,9 @@ + + + diff --git a/desktop/assets/windows/AppXManifest.xml b/desktop/assets/windows/AppXManifest.xml index 4d53ac2d375..be1e56919f4 100644 --- a/desktop/assets/windows/AppXManifest.xml +++ b/desktop/assets/windows/AppXManifest.xml @@ -6,7 +6,7 @@ + Version="3.7.1.0" /> Moodle Desktop Moodle Pty Ltd. diff --git a/package-lock.json b/package-lock.json index 24f38ee3fcf..c82b49deb62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "3.7.0", + "version": "3.7.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1589,23 +1589,15 @@ } }, "async-done": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.1.tgz", - "integrity": "sha512-R1BaUeJ4PMoLNJuk+0tLJgjmEqVsdN118+Z8O+alhnQDQgy0kmD5Mqi0DNEmMx2LM0Ed5yekKu+ZXYvIHceicg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", + "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==", "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.2", - "process-nextick-args": "^1.0.7", + "process-nextick-args": "^2.0.0", "stream-exhaust": "^1.0.1" - }, - "dependencies": { - "process-nextick-args": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "dev": true - } } }, "async-each": { @@ -2606,1228 +2598,5890 @@ "is-plain-object": "^2.0.1" } }, - "cordova-android": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-7.1.2.tgz", - "integrity": "sha512-w28HJGtfAZCT96hVH9BMppWMnmDTZplKu2NRQZN2dCr5e9r7aHpay41MYy9IBkh8+7E7lMo/jZkRwBDNr4VnEg==", + "cordova": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/cordova/-/cordova-8.1.2.tgz", + "integrity": "sha512-IfslM3MP42CA/ebVJVlit6FhQ2P6Fercwx9NNQjkVs0wahEwqamL4bcqh1gKiTti7+/ZsDtBRSVmRv+y7LcTbg==", "requires": { - "abbrev": "*", - "android-versions": "1.3.0", - "ansi": "*", - "balanced-match": "*", - "base64-js": "1.2.0", - "big-integer": "*", - "bplist-parser": "*", - "brace-expansion": "*", - "concat-map": "*", - "cordova-common": "2.2.5", - "cordova-registry-mapper": "*", - "elementtree": "0.1.6", - "glob": "5.0.15", - "inflight": "*", - "inherits": "*", - "minimatch": "*", - "nopt": "3.0.1", - "once": "*", - "path-is-absolute": "*", - "plist": "2.1.0", - "properties-parser": "0.2.3", - "q": "1.4.1", - "sax": "0.3.5", - "semver": "*", - "shelljs": "0.5.3", - "underscore": "*", - "unorm": "*", - "wrappy": "*", - "xmlbuilder": "8.2.2", - "xmldom": "*" + "configstore": "^3.1.2", + "cordova-common": "^2.2.0", + "cordova-lib": "8.1.1", + "editor": "1.0.0", + "insight": "^0.8.4", + "loud-rejection": "^1.6.0", + "nopt": "^4.0.1", + "update-notifier": "^2.5.0" }, "dependencies": { + "JSONStream": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.4.tgz", + "integrity": "sha512-Y7vfi3I5oMOYIr+WxV8NZxDSwcbNgzdKYsTNInmycOq9bUYwGg9ryu57Wg5NLmCjqdFPNUmpMBo3kSJN9tCbXg==", + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, "abbrev": { "version": "1.1.1", - "bundled": true + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, - "android-versions": { - "version": "1.3.0", - "bundled": true, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", "requires": { - "semver": "^5.4.1" + "mime-types": "~2.1.18", + "negotiator": "0.6.1" } }, - "ansi": { - "version": "0.3.1", - "bundled": true - }, - "balanced-match": { - "version": "1.0.0", - "bundled": true - }, - "base64-js": { - "version": "1.2.0", - "bundled": true - }, - "big-integer": { - "version": "1.6.32", - "bundled": true + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==" }, - "bplist-parser": { - "version": "0.1.1", - "bundled": true, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", "requires": { - "big-integer": "^1.6.7" + "acorn": "^3.0.4" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=" + } } }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, + "acorn-node": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.6.0.tgz", + "integrity": "sha512-ZsysjEh+Y3i14f7YXCAKJy99RXbd56wHKYBzN4FlFtICIZyFpYwK6OwNJhcz8A/FMtxoUZkJofH1v9KIfNgWmw==", "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "acorn": "^6.0.1", + "acorn-walk": "^6.0.1", + "xtend": "^4.0.1" + }, + "dependencies": { + "acorn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.0.2.tgz", + "integrity": "sha512-GXmKIvbrN3TV7aVqAzVFaMW8F8wzVX7voEBRO3bDA64+EX37YSayggRJP5Xig6HYHBkWKpFg9W5gg6orklubhg==" + } } }, - "concat-map": { - "version": "0.0.1", - "bundled": true + "acorn-walk": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.1.0.tgz", + "integrity": "sha512-ugTb7Lq7u4GfWSqqpwE0bGyoBZNMTok/zDBXxfEG0QM50jNlGhIWjRC1pPN7bvV1anhF+bs+/gNcRw+o55Evbg==" }, - "cordova-common": { - "version": "2.2.5", - "bundled": true, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", "requires": { - "ansi": "^0.3.1", - "bplist-parser": "^0.1.0", - "cordova-registry-mapper": "^1.1.8", - "elementtree": "0.1.6", - "glob": "^5.0.13", - "minimatch": "^3.0.0", - "plist": "^2.1.0", - "q": "^1.4.1", - "shelljs": "^0.5.3", - "underscore": "^1.8.3", - "unorm": "^1.3.3" + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" } }, - "cordova-registry-mapper": { - "version": "1.1.15", - "bundled": true + "ajv-keywords": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz", + "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=" }, - "elementtree": { - "version": "0.1.6", - "bundled": true, + "aliasify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aliasify/-/aliasify-2.1.0.tgz", + "integrity": "sha1-fDCCW5RQueYYW6J1M+r24gZ9S0I=", "requires": { - "sax": "0.3.5" + "browserify-transform-tools": "~1.7.0" } }, - "glob": { - "version": "5.0.15", - "bundled": true, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "optional": true + }, + "ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha1-DELU+xcWDVqa8eSEus4cZpIsGyE=" + }, + "ansi-align": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz", + "integrity": "sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=", "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "string-width": "^2.0.0" } }, - "inflight": { - "version": "1.0.6", - "bundled": true, + "ansi-escapes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", + "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==" + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "requires": { - "once": "^1.3.0", - "wrappy": "1" + "sprintf-js": "~1.0.2" } }, - "inherits": { - "version": "2.0.3", - "bundled": true + "array-filter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", + "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=" }, - "minimatch": { - "version": "3.0.4", - "bundled": true, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-map": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", + "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=" + }, + "array-reduce": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", + "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=" + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", "requires": { - "brace-expansion": "^1.1.7" + "array-uniq": "^1.0.1" } }, - "nopt": { - "version": "3.0.1", - "bundled": true, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=" + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=" + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", "requires": { - "abbrev": "1" + "safer-buffer": "~2.1.0" } }, - "once": { - "version": "1.4.0", - "bundled": true, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", "requires": { - "wrappy": "1" + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" } }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true - }, - "plist": { - "version": "2.1.0", - "bundled": true, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", "requires": { - "base64-js": "1.2.0", - "xmlbuilder": "8.2.2", - "xmldom": "0.1.x" + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=" + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "requires": { + "inherits": "2.0.1" + } + } } }, - "properties-parser": { - "version": "0.2.3", - "bundled": true + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, - "q": { - "version": "1.4.1", - "bundled": true + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" }, - "sax": { - "version": "0.3.5", - "bundled": true + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, - "semver": { - "version": "5.5.0", - "bundled": true + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, - "shelljs": { - "version": "0.5.3", - "bundled": true + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, - "underscore": { - "version": "1.9.1", - "bundled": true + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + } }, - "unorm": { - "version": "1.4.1", - "bundled": true + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, - "wrappy": { + "base64-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.0.tgz", + "integrity": "sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=" + }, + "bcrypt-pbkdf": { "version": "1.0.2", - "bundled": true + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } }, - "xmlbuilder": { - "version": "8.2.2", - "bundled": true + "big-integer": { + "version": "1.6.36", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.36.tgz", + "integrity": "sha512-t70bfa7HYEA1D9idDbmuv7YbsbVkQ+Hp+8KFSul4aE5e/i1bjCNIRYJZlA8Q8p0r9T8cF/RVvwUgRA//FydEyg==" + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "requires": { + "inherits": "~2.0.0" + } }, - "xmldom": { - "version": "0.1.27", - "bundled": true - } - } - }, - "cordova-android-support-gradle-release": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cordova-android-support-gradle-release/-/cordova-android-support-gradle-release-3.0.0.tgz", - "integrity": "sha512-vyiqQ6N9Qb+4xRizWSpUX/LyJ1HaDN0piWc8xoS9Hx9YodIS3vyi1UpQyfLQmCixoeLVcRieKXjuSMXnUrv1dw==", - "requires": { - "q": "^1.4.1", - "semver": "5.6.0" - }, - "dependencies": { - "semver": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", - "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" - } - } - }, - "cordova-clipboard": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cordova-clipboard/-/cordova-clipboard-1.2.1.tgz", - "integrity": "sha512-WTGxyQJYsgmll8wDEo0u4XevZDUH1ZH1VPoOwwNkQ8YOtCNQS8gRIIVtZ70Kan+Vo+CiUMV0oJXdNAdARb8JwQ==" - }, - "cordova-ios": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/cordova-ios/-/cordova-ios-4.5.5.tgz", - "integrity": "sha512-3+30m2dZ2yii7kg+H7cgpdpkXpMj54zoX5imjGGG4Z7dPXKmalTLc/9rLq+Iaa+Q1BqyOrUFaHopWOODRU6vCg==", - "requires": { - "abbrev": "*", - "ansi": "*", - "balanced-match": "*", - "base64-js": "1.2.0", - "big-integer": "*", - "bplist-creator": "*", - "bplist-parser": "*", - "brace-expansion": "*", - "concat-map": "*", - "cordova-common": "2.2.5", - "cordova-registry-mapper": "*", - "elementtree": "0.1.6", - "glob": "5.0.15", - "inflight": "*", - "inherits": "*", - "ios-sim": "6.1.3", - "minimatch": "*", - "nopt": "3.0.6", - "once": "*", - "path-is-absolute": "*", - "plist": "2.1.0", - "q": "1.5.1", - "sax": "0.3.5", - "shelljs": "0.5.3", - "simctl": "*", - "simple-plist": "0.2.1", - "stream-buffers": "2.2.0", - "tail": "0.4.0", - "underscore": "*", - "unorm": "*", - "uuid": "3.0.1", - "wrappy": "*", - "xcode": "0.9.3", - "xml-escape": "1.1.0", - "xmlbuilder": "8.2.2", - "xmldom": "*" - }, - "dependencies": { - "abbrev": { - "version": "1.1.1", - "bundled": true - }, - "ansi": { - "version": "0.3.1", - "bundled": true - }, - "balanced-match": { - "version": "1.0.0", - "bundled": true + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" }, - "base64-js": { - "version": "1.2.0", - "bundled": true + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.1", + "http-errors": "~1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "~2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "~1.6.15" + } }, - "big-integer": { - "version": "1.6.32", - "bundled": true + "boxen": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz", + "integrity": "sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==", + "requires": { + "ansi-align": "^2.0.0", + "camelcase": "^4.0.0", + "chalk": "^2.0.1", + "cli-boxes": "^1.0.0", + "string-width": "^2.0.0", + "term-size": "^1.2.0", + "widest-line": "^2.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } }, "bplist-creator": { "version": "0.0.7", - "bundled": true, + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz", + "integrity": "sha1-N98VNgkoJLh8QvlXsBNEEXNyrkU=", "requires": { "stream-buffers": "~2.2.0" } }, "bplist-parser": { "version": "0.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.1.1.tgz", + "integrity": "sha1-1g1dzCDLptx+HymbNdPh+V2vuuY=", "requires": { "big-integer": "^1.6.7" } }, "brace-expansion": { "version": "1.1.11", - "bundled": true, + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "concat-map": { - "version": "0.0.1", - "bundled": true - }, - "cordova-common": { - "version": "2.2.5", - "bundled": true, - "requires": { - "ansi": "^0.3.1", - "bplist-parser": "^0.1.0", - "cordova-registry-mapper": "^1.1.8", - "elementtree": "0.1.6", - "glob": "^5.0.13", - "minimatch": "^3.0.0", - "plist": "^2.1.0", - "q": "^1.4.1", - "shelljs": "^0.5.3", - "underscore": "^1.8.3", - "unorm": "^1.3.3" - } - }, - "cordova-registry-mapper": { - "version": "1.1.15", - "bundled": true - }, - "elementtree": { - "version": "0.1.6", - "bundled": true, - "requires": { - "sax": "0.3.5" - } + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, - "glob": { - "version": "5.0.15", - "bundled": true, + "browser-pack": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", + "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "JSONStream": "^1.0.3", + "combine-source-map": "~0.8.0", + "defined": "^1.0.0", + "safe-buffer": "^5.1.1", + "through2": "^2.0.0", + "umd": "^3.0.0" } }, - "inflight": { - "version": "1.0.6", - "bundled": true, + "browser-resolve": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", + "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==", "requires": { - "once": "^1.3.0", - "wrappy": "1" + "resolve": "1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=" + } } }, - "inherits": { - "version": "2.0.3", - "bundled": true - }, - "ios-sim": { - "version": "6.1.3", - "bundled": true, + "browserify": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/browserify/-/browserify-14.4.0.tgz", + "integrity": "sha1-CJo0Y69Y0OSNjNQHCz90ZU1avKk=", "requires": { - "bplist-parser": "^0.0.6", - "nopt": "1.0.9", - "plist": "^2.1.0", - "simctl": "^1.1.1" + "JSONStream": "^1.0.3", + "assert": "^1.4.0", + "browser-pack": "^6.0.1", + "browser-resolve": "^1.11.0", + "browserify-zlib": "~0.1.2", + "buffer": "^5.0.2", + "cached-path-relative": "^1.0.0", + "concat-stream": "~1.5.1", + "console-browserify": "^1.1.0", + "constants-browserify": "~1.0.0", + "crypto-browserify": "^3.0.0", + "defined": "^1.0.0", + "deps-sort": "^2.0.0", + "domain-browser": "~1.1.0", + "duplexer2": "~0.1.2", + "events": "~1.1.0", + "glob": "^7.1.0", + "has": "^1.0.0", + "htmlescape": "^1.1.0", + "https-browserify": "^1.0.0", + "inherits": "~2.0.1", + "insert-module-globals": "^7.0.0", + "labeled-stream-splicer": "^2.0.0", + "module-deps": "^4.0.8", + "os-browserify": "~0.1.1", + "parents": "^1.0.1", + "path-browserify": "~0.0.0", + "process": "~0.11.0", + "punycode": "^1.3.2", + "querystring-es3": "~0.2.0", + "read-only-stream": "^2.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.1.4", + "shasum": "^1.0.0", + "shell-quote": "^1.6.1", + "stream-browserify": "^2.0.0", + "stream-http": "^2.0.0", + "string_decoder": "~1.0.0", + "subarg": "^1.0.0", + "syntax-error": "^1.1.1", + "through2": "^2.0.0", + "timers-browserify": "^1.0.1", + "tty-browserify": "~0.0.0", + "url": "~0.11.0", + "util": "~0.10.1", + "vm-browserify": "~0.0.1", + "xtend": "^4.0.0" }, "dependencies": { - "bplist-parser": { - "version": "0.0.6", - "bundled": true - }, - "nopt": { - "version": "1.0.9", - "bundled": true, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "requires": { - "abbrev": "1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" } } } }, - "minimatch": { - "version": "3.0.4", - "bundled": true, + "browserify-aes": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "requires": { - "brace-expansion": "^1.1.7" + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" } }, - "nopt": { - "version": "3.0.6", - "bundled": true, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", "requires": { - "abbrev": "1" + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" } }, - "once": { - "version": "1.4.0", - "bundled": true, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", "requires": { - "wrappy": "1" + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" } }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true - }, - "plist": { - "version": "2.1.0", - "bundled": true, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", "requires": { - "base64-js": "1.2.0", - "xmlbuilder": "8.2.2", - "xmldom": "0.1.x" + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" } }, - "q": { - "version": "1.5.1", - "bundled": true - }, - "sax": { - "version": "0.3.5", - "bundled": true + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } }, - "shelljs": { - "version": "0.5.3", - "bundled": true + "browserify-transform-tools": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/browserify-transform-tools/-/browserify-transform-tools-1.7.0.tgz", + "integrity": "sha1-g+J3Ih9jJZvtLn6yooOpcKUB9MQ=", + "requires": { + "falafel": "^2.0.0", + "through": "^2.3.7" + } }, - "simctl": { - "version": "1.1.1", - "bundled": true, + "browserify-zlib": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", + "integrity": "sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=", "requires": { - "shelljs": "^0.2.6", - "tail": "^0.4.0" - }, - "dependencies": { - "shelljs": { - "version": "0.2.6", - "bundled": true - } + "pako": "~0.2.0" } }, - "simple-plist": { - "version": "0.2.1", - "bundled": true, + "buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", + "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", "requires": { - "bplist-creator": "0.0.7", - "bplist-parser": "0.1.1", - "plist": "2.0.1" - }, - "dependencies": { - "base64-js": { - "version": "1.1.2", - "bundled": true - }, - "plist": { - "version": "2.0.1", - "bundled": true, - "requires": { - "base64-js": "1.1.2", - "xmlbuilder": "8.2.2", - "xmldom": "0.1.x" - } - } + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" } }, - "stream-buffers": { - "version": "2.2.0", - "bundled": true + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, - "tail": { - "version": "0.4.0", - "bundled": true + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=" }, - "underscore": { - "version": "1.9.1", - "bundled": true + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=" }, - "unorm": { - "version": "1.4.1", - "bundled": true + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, - "uuid": { - "version": "3.0.1", - "bundled": true + "builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha1-y5T662HIaWRR2zZTThQi+U8K7og=" }, - "wrappy": { - "version": "1.0.2", - "bundled": true + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" }, - "xcode": { - "version": "0.9.3", - "bundled": true, + "cached-path-relative": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.1.tgz", + "integrity": "sha1-0JxLUoAKpMB44t2BqGmqyQ0uVOc=" + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", "requires": { - "pegjs": "^0.10.0", - "simple-plist": "^0.2.1", - "uuid": "3.0.1" + "callsites": "^0.2.0" } }, - "xml-escape": { - "version": "1.1.0", - "bundled": true + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=" }, - "xmlbuilder": { - "version": "8.2.2", - "bundled": true + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=" }, - "xmldom": { - "version": "0.1.27", - "bundled": true - } - } - }, - "cordova-plugin-badge": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/cordova-plugin-badge/-/cordova-plugin-badge-0.8.8.tgz", - "integrity": "sha512-RhIBtd5xhD/iLnxjt35jvOae28oNW/wtMZBOmQR3Rf0y4wirvA1bpAZEhBoFqL+rZGhsd6ddOdQXdex1T0DRyQ==" - }, - "cordova-plugin-camera": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/cordova-plugin-camera/-/cordova-plugin-camera-4.0.3.tgz", - "integrity": "sha1-c3Olk4MYyGzP2E43E+I4LRD+B2s=" - }, - "cordova-plugin-customurlscheme": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/cordova-plugin-customurlscheme/-/cordova-plugin-customurlscheme-4.3.0.tgz", - "integrity": "sha1-Avlod4tAk5kOsEB/P6GxRY1wX5Q=" - }, - "cordova-plugin-device": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/cordova-plugin-device/-/cordova-plugin-device-2.0.2.tgz", - "integrity": "sha1-/Ajzci5n7ve2xnv8mag99q3Quro=" - }, - "cordova-plugin-file": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cordova-plugin-file/-/cordova-plugin-file-6.0.1.tgz", - "integrity": "sha1-SWBrjBWlaI1HKPkuSnMloGHeB/U=" - }, - "cordova-plugin-file-opener2": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/cordova-plugin-file-opener2/-/cordova-plugin-file-opener2-2.0.19.tgz", - "integrity": "sha1-yjrhIlOVt3qx/lsgrMv+zGiOJJM=" - }, - "cordova-plugin-file-transfer": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/cordova-plugin-file-transfer/-/cordova-plugin-file-transfer-1.7.1.tgz", - "integrity": "sha1-p12L4uvDu5sjxbG70ZkhTsJnWGs=" - }, - "cordova-plugin-globalization": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/cordova-plugin-globalization/-/cordova-plugin-globalization-1.11.0.tgz", - "integrity": "sha1-6sMVgQAphJOvowvolA5pj2HvvP4=" - }, - "cordova-plugin-inappbrowser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cordova-plugin-inappbrowser/-/cordova-plugin-inappbrowser-3.0.0.tgz", - "integrity": "sha1-1K4A02Z2IQdRBXrSWK5K1KkWGto=" - }, - "cordova-plugin-ionic-keyboard": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.1.3.tgz", - "integrity": "sha512-6ucQ6FdlLdBm8kJfFnzozmBTjru/0xekHP/dAhjoCZggkGRlgs8TsUJFkxa/bV+qi7Dlo50JjmpE4UMWAO+aOQ==" - }, - "cordova-plugin-local-notification": { - "version": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#5b2f3073a1c1fb39cad3566be792445c343db2c6", - "from": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle" - }, - "cordova-plugin-media-capture": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.2.tgz", - "integrity": "sha1-2mV8L6rc/H/cKGjlnSFe2D5wDDo=" - }, - "cordova-plugin-network-information": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cordova-plugin-network-information/-/cordova-plugin-network-information-2.0.1.tgz", - "integrity": "sha1-6QQh9DDGq3bUCSI/Jfzvu7zhdpA=" - }, - "cordova-plugin-screen-orientation": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/cordova-plugin-screen-orientation/-/cordova-plugin-screen-orientation-3.0.1.tgz", - "integrity": "sha1-daNXzik4CB6PYdRgU5S213Rjwfg=" - }, - "cordova-plugin-splashscreen": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/cordova-plugin-splashscreen/-/cordova-plugin-splashscreen-5.0.2.tgz", - "integrity": "sha1-dH509W4gHNWFvGLRS8oZ9oZ/8e0=" - }, - "cordova-plugin-statusbar": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/cordova-plugin-statusbar/-/cordova-plugin-statusbar-2.4.2.tgz", - "integrity": "sha1-/B+9wNjXAzp+jh8ff/FnrJvU+vY=" - }, - "cordova-plugin-whitelist": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/cordova-plugin-whitelist/-/cordova-plugin-whitelist-1.3.3.tgz", - "integrity": "sha1-tehezbv+Wu3tQKG/TuI3LmfZb7Q=" - }, - "cordova-plugin-zip": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cordova-plugin-zip/-/cordova-plugin-zip-3.1.0.tgz", - "integrity": "sha1-F2yCSOog058c+VnvXmFWrMqWshc=" - }, - "cordova-sqlite-storage": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/cordova-sqlite-storage/-/cordova-sqlite-storage-2.6.0.tgz", - "integrity": "sha512-m+KylFwNRsQloJ6TZihupfma2muXkmNglh8cFSiJQqriTTm6eq0gyPaAuKxgoPswe679G+0aUai07NFC/f0GGQ==", - "requires": { - "cordova-sqlite-storage-dependencies": "1.2.1" - } - }, - "cordova-sqlite-storage-dependencies": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cordova-sqlite-storage-dependencies/-/cordova-sqlite-storage-dependencies-1.2.1.tgz", - "integrity": "sha512-4ihQApBGVKR1QZ4oOSGctKFfthtCfiWMTcIIfxe97vKxlvGr9NyXOvYG9vXU9S7yVR7Ua+Rj47hkE7pQIKvQTg==" - }, - "cordova-support-google-services": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cordova-support-google-services/-/cordova-support-google-services-1.2.1.tgz", - "integrity": "sha512-EnFjKAE9oI2uzyUvEfWpLgTM200nuJVvShaA4yyz9wMKBUN+H/BRG1byd1ibZz3sSihNKi3FxjQPxmmEn6/IfA==" - }, - "core-js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.3.0.tgz", - "integrity": "sha1-+rg/uwstjchfpjbEudNMdUIMbWU=" - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - }, - "create-ecdh": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", - "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", - "dev": true, - "requires": { - "bn.js": "^4.1.0", - "elliptic": "^6.0.0" - } - }, - "create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "dev": true, - "requires": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", - "dev": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "crypto-browserify": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", - "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", - "dev": true, - "requires": { - "browserify-cipher": "^1.0.0", - "browserify-sign": "^4.0.0", - "create-ecdh": "^4.0.0", - "create-hash": "^1.1.0", - "create-hmac": "^1.1.0", - "diffie-hellman": "^5.0.0", - "inherits": "^2.0.1", - "pbkdf2": "^3.0.3", - "public-encrypt": "^4.0.0", - "randombytes": "^2.0.0", - "randomfill": "^1.0.3" - } - }, - "currently-unhandled": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", - "dev": true, - "requires": { - "array-find-index": "^1.0.1" - } - }, - "d": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", - "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", - "dev": true, - "requires": { - "es5-ext": "^0.10.9" - } - }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0" - }, - "dependencies": { - "assert-plus": { + "capture-stack-trace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz", + "integrity": "sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==" + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=" + }, + "ci-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", + "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==" + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==" + }, + "cli-boxes": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - } - } - }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true - }, - "dateformat": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", - "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", - "dev": true - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "dev": true - }, - "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true - }, - "default-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", - "integrity": "sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==", - "dev": true, - "requires": { - "kind-of": "^5.0.2" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, - "default-resolution": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-resolution/-/default-resolution-2.0.0.tgz", - "integrity": "sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ=", - "dev": true - }, - "define-properties": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", - "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", - "requires": { - "foreach": "^2.0.5", - "object-keys": "^1.0.8" - } - }, - "define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "requires": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "dependencies": { - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz", + "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=" + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", "requires": { - "kind-of": "^6.0.0" + "restore-cursor": "^2.0.0" } }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=" + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "requires": { - "kind-of": "^6.0.0" + "color-name": "1.1.3" } }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "combine-source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", + "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=", "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" + "convert-source-map": "~1.1.0", + "inline-source-map": "~0.6.0", + "lodash.memoize": "~3.0.3", + "source-map": "~0.5.3" } }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true + "combined-stream": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", + "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", + "requires": { + "delayed-stream": "~1.0.0" + } }, - "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", - "dev": true - } - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", - "dev": true - }, - "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", - "dev": true - }, - "des.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", - "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", - "dev": true - }, - "detect-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", - "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", - "dev": true - }, - "detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", - "dev": true - }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true - }, - "diffie-hellman": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "dev": true, - "requires": { - "bn.js": "^4.1.0", - "miller-rabin": "^4.0.0", - "randombytes": "^2.0.0" - } - }, - "doctrine": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-0.7.2.tgz", - "integrity": "sha1-fLhgNZujvpDgQLJrcpzkv6ZUxSM=", - "dev": true, - "requires": { - "esutils": "^1.1.6", - "isarray": "0.0.1" - }, - "dependencies": { - "esutils": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.1.6.tgz", - "integrity": "sha1-wBzKqa5LiXxtDD4hCuUvPHqEQ3U=", - "dev": true + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "optional": true }, - "isarray": { + "compressible": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.15.tgz", + "integrity": "sha512-4aE67DL33dSW9gw4CI2H/yTxqHLNcxp0yS6jB+4h+wr3e43+1z7vm0HU9qXOH8j+qjKuL8+UtkOxYQSMq60Ylw==", + "requires": { + "mime-db": ">= 1.36.0 < 2" + } + }, + "compression": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.3.tgz", + "integrity": "sha512-HSjyBG5N1Nnz7tF2+O7A9XUhyjru71/fwgNb7oIsEVHR0WShfs2tIS/EySLgiTe98aOK18YDlMXpzjCXY/n9mg==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.14", + "debug": "2.6.9", + "on-headers": "~1.0.1", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + } + }, + "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - } - } - }, - "domain-browser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", - "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", - "dev": true - }, - "dotenv": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz", - "integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==", - "dev": true - }, - "dotenv-defaults": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-1.0.2.tgz", - "integrity": "sha512-iXFvHtXl/hZPiFj++1hBg4lbKwGM+t/GlvELDnRtOFdjXyWP7mubkVr+eZGWG62kdsbulXAef6v/j6kiWc/xGA==", - "dev": true, - "requires": { - "dotenv": "^6.2.0" - } - }, - "dotenv-expand": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-4.2.0.tgz", - "integrity": "sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU=", - "dev": true - }, - "dotenv-webpack": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-1.7.0.tgz", - "integrity": "sha512-wwNtOBW/6gLQSkb8p43y0Wts970A3xtNiG/mpwj9MLUhtPCQG6i+/DSXXoNN7fbPCU/vQ7JjwGmgOeGZSSZnsw==", - "dev": true, - "requires": { - "dotenv-defaults": "^1.0.2" - } - }, - "duplexer2": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", - "integrity": "sha1-xhTc9n4vsUmVqRcR5aYX6KYKMds=", - "dev": true, - "requires": { - "readable-stream": "~1.1.9" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, - "readable-stream": { - "version": "1.1.14", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "dev": true, + "concat-stream": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.2.tgz", + "integrity": "sha1-cIl4Yk2FavQaWnQd790mHadSwmY=", "requires": { - "core-util-is": "~1.0.0", "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" + "readable-stream": "~2.0.0", + "typedarray": "~0.0.5" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "readable-stream": { + "version": "2.0.6", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + } } }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - } - } - }, - "duplexify": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.6.0.tgz", - "integrity": "sha512-fO3Di4tBKJpYTFHAxTU00BcfWMY9w24r/x21a6rZRbsD/ToUgGxsMbiGRmB7uVAXeGKXD9MwiLZa5E97EVgIRQ==", - "dev": true, - "requires": { - "end-of-stream": "^1.0.0", - "inherits": "^2.0.1", - "readable-stream": "^2.0.0", - "stream-shift": "^1.0.0" - } - }, - "each-props": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", - "integrity": "sha512-vV0Hem3zAGkJAyU7JSjixeU66rwdynTAa1vofCrSA5fEln+m67Az9CcnkVD776/fsN/UjIWmBDoNRS6t6G9RfA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.1", - "object.defaults": "^1.1.0" - } - }, - "ecc-jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", - "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", - "dev": true, - "optional": true, - "requires": { - "jsbn": "~0.1.0" - } - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", - "dev": true - }, - "ejs": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.6.1.tgz", - "integrity": "sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==", - "dev": true - }, - "electron-builder-lib": { - "version": "20.23.1", - "resolved": "https://registry.npmjs.org/electron-builder-lib/-/electron-builder-lib-20.23.1.tgz", - "integrity": "sha512-9bYeANVqFPpSmswPwXv8efu9voPE1Q8hw/jNwiWGICjPeYjHyKwa4ao+Vd1beY6ZhUDVhxxXIdlJWnmvH7Mcxw==", - "dev": true, - "requires": { - "7zip-bin": "~4.0.2", - "app-builder-bin": "1.11.4", - "async-exit-hook": "^2.0.1", - "bluebird-lst": "^1.0.5", - "builder-util": "5.17.0", - "builder-util-runtime": "4.4.1", - "chromium-pickle-js": "^0.2.0", - "debug": "^3.1.0", - "ejs": "^2.6.1", - "electron-osx-sign": "0.4.10", - "electron-publish": "20.23.1", - "env-paths": "^1.0.0", - "fs-extra-p": "^4.6.1", - "hosted-git-info": "^2.7.1", - "is-ci": "^1.1.0", - "isbinaryfile": "^3.0.2", - "js-yaml": "^3.12.0", - "lazy-val": "^1.0.3", - "minimatch": "^3.0.4", - "normalize-package-data": "^2.4.0", - "plist": "^3.0.1", - "read-config-file": "3.1.0", - "sanitize-filename": "^1.6.1", - "semver": "^5.5.0", - "sumchecker": "^2.0.2", - "temp-file": "^3.1.3" - }, - "dependencies": { - "app-builder-bin": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-1.11.4.tgz", - "integrity": "sha512-04sgoFSz6q5pbAxAXcxfUFPl16gJsay5b8dudFXUwQbFfS7ox2uGgbOO5LGHF0t7sM7q/N82ztGePuuCSkKZHQ==", - "dev": true - }, - "builder-util-runtime": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-4.4.1.tgz", - "integrity": "sha512-8L2pbL6D3VdI1f8OMknlZJpw0c7KK15BRz3cY77AOUElc4XlCv2UhVV01jJM7+6Lx7henaQh80ALULp64eFYAQ==", - "dev": true, + "configstore": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz", + "integrity": "sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==", "requires": { - "bluebird-lst": "^1.0.5", - "debug": "^3.1.0", - "fs-extra-p": "^4.6.1", - "sax": "^1.2.4" + "dot-prop": "^4.1.0", + "graceful-fs": "^4.1.2", + "make-dir": "^1.0.0", + "unique-string": "^1.0.0", + "write-file-atomic": "^2.0.0", + "xdg-basedir": "^3.0.0" } }, - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", "requires": { - "ms": "^2.1.1" + "date-now": "^0.1.4" } }, - "hosted-git-info": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", - "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", - "dev": true + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=" }, - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", - "dev": true - } - } - }, - "electron-osx-sign": { - "version": "0.4.10", - "resolved": "http://registry.npmjs.org/electron-osx-sign/-/electron-osx-sign-0.4.10.tgz", - "integrity": "sha1-vk87ibKnWh3F8eckkIGrKSnKOiY=", - "dev": true, - "requires": { - "bluebird": "^3.5.0", - "compare-version": "^0.1.2", - "debug": "^2.6.8", - "isbinaryfile": "^3.0.2", - "minimist": "^1.2.0", - "plist": "^2.1.0" - }, - "dependencies": { - "base64-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.0.tgz", - "integrity": "sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=", - "dev": true + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=" }, - "plist": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-2.1.0.tgz", - "integrity": "sha1-V8zbeggh3yGDEhejytVOPhRqECU=", - "dev": true, - "requires": { - "base64-js": "1.2.0", - "xmlbuilder": "8.2.2", - "xmldom": "0.1.x" - } + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" }, - "xmlbuilder": { - "version": "8.2.2", - "resolved": "http://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", - "integrity": "sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=", - "dev": true - } - } - }, - "electron-publish": { - "version": "20.23.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-20.23.1.tgz", - "integrity": "sha512-FsNL5bY4/Uab5YGYWE+/7wL8znkghEPwJvDyD0985Obw0Eg9JZP1MpUbt4jUxrK8z5wTLVMFdSv7ymV8yq6ypA==", - "dev": true, - "requires": { - "bluebird-lst": "^1.0.5", - "builder-util": "~5.17.0", - "builder-util-runtime": "^4.4.1", - "chalk": "^2.4.1", - "fs-extra-p": "^4.6.1", - "lazy-val": "^1.0.3", - "mime": "^2.3.1" - }, - "dependencies": { - "builder-util-runtime": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-4.4.1.tgz", - "integrity": "sha512-8L2pbL6D3VdI1f8OMknlZJpw0c7KK15BRz3cY77AOUElc4XlCv2UhVV01jJM7+6Lx7henaQh80ALULp64eFYAQ==", - "dev": true, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "convert-source-map": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", + "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cordova-app-hello-world": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cordova-app-hello-world/-/cordova-app-hello-world-3.12.0.tgz", + "integrity": "sha1-Jw4Gtnsq6UvP7mWS7TnrQjA9GG8=" + }, + "cordova-common": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-2.2.5.tgz", + "integrity": "sha1-+TzvKtSUz8v1bEbj1hKqqctfzDI=", "requires": { - "bluebird-lst": "^1.0.5", - "debug": "^3.1.0", - "fs-extra-p": "^4.6.1", - "sax": "^1.2.4" + "ansi": "^0.3.1", + "bplist-parser": "^0.1.0", + "cordova-registry-mapper": "^1.1.8", + "elementtree": "0.1.6", + "glob": "^5.0.13", + "minimatch": "^3.0.0", + "plist": "^2.1.0", + "q": "^1.4.1", + "shelljs": "^0.5.3", + "underscore": "^1.8.3", + "unorm": "^1.3.3" } }, - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, + "cordova-create": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cordova-create/-/cordova-create-1.1.2.tgz", + "integrity": "sha1-g7CScbN40cA7x9mnhv7dYEhcPM8=", "requires": { - "ms": "^2.1.1" + "cordova-app-hello-world": "^3.11.0", + "cordova-common": "^2.2.0", + "cordova-fetch": "^1.3.0", + "q": "1.0.1", + "shelljs": "0.3.0", + "valid-identifier": "0.0.1" + }, + "dependencies": { + "q": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.0.1.tgz", + "integrity": "sha1-EYcq7t7okmgRCxCnGESP+xARKhQ=" + }, + "shelljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", + "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=" + } } }, - "mime": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz", - "integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==", - "dev": true - }, - "ms": { - "version": "2.1.1", + "cordova-fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cordova-fetch/-/cordova-fetch-1.3.1.tgz", + "integrity": "sha512-/0PNQUPxHvVcjlzVQcydD5BQtfx1XdCfzQ2KigdtZma5oVVUtR4IxfnYB15RuT/GVb/SGRLvR5AIi2Gd5Gb+mg==", + "requires": { + "cordova-common": "^2.2.5", + "dependency-ls": "^1.1.0", + "hosted-git-info": "^2.5.0", + "is-git-url": "^1.0.0", + "is-url": "^1.2.1", + "q": "^1.4.1", + "shelljs": "^0.7.0" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "shelljs": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.7.8.tgz", + "integrity": "sha1-3svPh0sNHl+3LhSxZKloMEjprLM=", + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + } + } + }, + "cordova-js": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/cordova-js/-/cordova-js-4.2.4.tgz", + "integrity": "sha512-Qy0O3w/gwbIqIJzlyCy60nPwJlF1c74ELpsfDIGXB92/uST5nQSSUDVDP4UOfb/c6OU7yPqxhCWOGROyTYKPDw==", + "requires": { + "browserify": "14.4.0" + } + }, + "cordova-lib": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/cordova-lib/-/cordova-lib-8.1.1.tgz", + "integrity": "sha512-PcrlEGRGubV2c9ThcSwoVtN/1hKQ0qtwRopl4188rD10gjtt8K+NSKrnRqh6Ia5PouVUUOZBrlhBxDd5BRbfeg==", + "requires": { + "aliasify": "^2.1.0", + "cordova-common": "^2.2.0", + "cordova-create": "^1.1.0", + "cordova-fetch": "^1.3.0", + "cordova-js": "^4.2.2", + "cordova-serve": "^2.0.0", + "dep-graph": "1.1.0", + "dependency-ls": "^1.1.1", + "detect-indent": "^5.0.0", + "elementtree": "^0.1.7", + "glob": "^7.1.2", + "init-package-json": "^1.2.0", + "nopt": "4.0.1", + "opener": "^1.4.3", + "plist": "2.0.1", + "properties-parser": "0.3.1", + "q": "^1.5.1", + "read-chunk": "^2.1.0", + "request": "^2.88.0", + "semver": "^5.3.0", + "shebang-command": "^1.2.0", + "shelljs": "0.3.0", + "tar": "^2.2.1", + "underscore": "^1.9.0", + "unorm": "^1.4.1", + "valid-identifier": "0.0.1", + "which": "^1.3.1", + "xcode": "^1.0.0" + }, + "dependencies": { + "base64-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.1.2.tgz", + "integrity": "sha1-1kAMrBxMZgl22Q0HoENR2JOV9eg=" + }, + "elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha1-mskb5uUvtuYkTE5UpKw+2K6OKcA=", + "requires": { + "sax": "1.1.4" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "plist": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-2.0.1.tgz", + "integrity": "sha1-CjLKlIGxw2TpLhjcVch23p0B2os=", + "requires": { + "base64-js": "1.1.2", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + }, + "sax": { + "version": "1.1.4", + "resolved": "http://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha1-dLbTPJrh4AFRDxeakRaFiPGu2qk=" + }, + "shelljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", + "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=" + } + } + }, + "cordova-registry-mapper": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/cordova-registry-mapper/-/cordova-registry-mapper-1.1.15.tgz", + "integrity": "sha1-4kS5GFuBdUc7/2B5MkkFEV+D3Hw=" + }, + "cordova-serve": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cordova-serve/-/cordova-serve-2.0.1.tgz", + "integrity": "sha512-3Xl1D5eyiQlY5ow6Kn/say0us2TqSw/zgQmyTLxbewTngQZ1CIqxmqD7EFGoCNBrB4HsdPmpiSpFCitybKQN9g==", + "requires": { + "chalk": "^1.1.1", + "compression": "^1.6.0", + "express": "^4.13.3", + "opn": "^5.3.0", + "shelljs": "^0.5.3" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-error-class": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz", + "integrity": "sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=", + "requires": { + "capture-stack-trace": "^1.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=" + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "requires": { + "array-find-index": "^1.0.1" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "dep-graph": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/dep-graph/-/dep-graph-1.1.0.tgz", + "integrity": "sha1-+t6GqSeZqBPptCURzfPfpsyNvv4=", + "requires": { + "underscore": "1.2.1" + }, + "dependencies": { + "underscore": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.2.1.tgz", + "integrity": "sha1-/FxrB2VnPZKi1KyLTcCqiHAuK9Q=" + } + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "dependency-ls": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/dependency-ls/-/dependency-ls-1.1.1.tgz", + "integrity": "sha1-BIGwfwI9dM4xEZLlxpDRPhhgAFQ=", + "requires": { + "q": "1.4.1" + }, + "dependencies": { + "q": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz", + "integrity": "sha1-VXBbzZPF82c1MMLCy8DCs63cKG4=" + } + } + }, + "deps-sort": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.0.tgz", + "integrity": "sha1-CRckkC6EZYJg65EHSMzNGvbiH7U=", + "requires": { + "JSONStream": "^1.0.3", + "shasum": "^1.0.0", + "subarg": "^1.0.0", + "through2": "^2.0.0" + } + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-indent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", + "integrity": "sha1-OHHMCmoALow+Wzz38zYmRnXwa50=" + }, + "detective": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/detective/-/detective-4.7.1.tgz", + "integrity": "sha512-H6PmeeUcZloWtdt4DAkFyzFL94arpHr3NOwwmVILFiy+9Qd4JTxxXrzfyGk/lmct2qVGBwTSwSXagqu2BxmWig==", + "requires": { + "acorn": "^5.2.1", + "defined": "^1.0.0" + } + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "requires": { + "esutils": "^2.0.2" + } + }, + "domain-browser": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz", + "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=" + }, + "dot-prop": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", + "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==", + "requires": { + "is-obj": "^1.0.0" + } + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "requires": { + "readable-stream": "^2.0.2" + } + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "optional": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "editor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/editor/-/editor-1.0.0.tgz", + "integrity": "sha1-YMf4e9YrzGqJT6jM1q+3gjok90I=" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "elementtree": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.6.tgz", + "integrity": "sha1-KsTEbqMFFsjEy9teOsdBjlkt4gw=", + "requires": { + "sax": "0.3.5" + } + }, + "elliptic": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", + "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==", + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + }, + "dependencies": { + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=" + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=" + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "eslint": { + "version": "4.19.1", + "resolved": "http://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", + "requires": { + "ajv": "^5.3.0", + "babel-code-frame": "^6.22.0", + "chalk": "^2.1.0", + "concat-stream": "^1.6.0", + "cross-spawn": "^5.1.0", + "debug": "^3.1.0", + "doctrine": "^2.1.0", + "eslint-scope": "^3.7.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^3.5.4", + "esquery": "^1.0.0", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.0.1", + "ignore": "^3.3.3", + "imurmurhash": "^0.1.4", + "inquirer": "^3.0.6", + "is-resolvable": "^1.0.0", + "js-yaml": "^3.9.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.4", + "minimatch": "^3.0.2", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "pluralize": "^7.0.0", + "progress": "^2.0.0", + "regexpp": "^1.0.1", + "require-uncached": "^1.0.3", + "semver": "^5.3.0", + "strip-ansi": "^4.0.0", + "strip-json-comments": "~2.0.1", + "table": "4.0.2", + "text-table": "~0.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "requires": { + "ms": "^2.1.1" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "eslint-config-semistandard": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-semistandard/-/eslint-config-semistandard-12.0.1.tgz", + "integrity": "sha512-4zaPW5uRFasf2uRZkE19Y+W84KBV3q+oyWYOsgUN+5DQXE5HCsh7ZxeWDXxozk7NPycGm0kXcsJzLe5GZ1jCeg==" + }, + "eslint-config-standard": { + "version": "11.0.0", + "resolved": "http://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-11.0.0.tgz", + "integrity": "sha512-oDdENzpViEe5fwuRCWla7AXQd++/oyIp8zP+iP9jiUPG6NBj3SHgdgtl/kTn00AjeN+1HNvavTKmYbMo+xMOlw==" + }, + "eslint-import-resolver-node": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz", + "integrity": "sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q==", + "requires": { + "debug": "^2.6.9", + "resolve": "^1.5.0" + } + }, + "eslint-module-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.2.0.tgz", + "integrity": "sha1-snA2LNiLGkitMIl2zn+lTphBF0Y=", + "requires": { + "debug": "^2.6.8", + "pkg-dir": "^1.0.0" + } + }, + "eslint-plugin-import": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.14.0.tgz", + "integrity": "sha512-FpuRtniD/AY6sXByma2Wr0TXvXJ4nA/2/04VPlfpmUDPOpOY264x+ILiwnrk/k4RINgDAyFZByxqPUbSQ5YE7g==", + "requires": { + "contains-path": "^0.1.0", + "debug": "^2.6.8", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.1", + "eslint-module-utils": "^2.2.0", + "has": "^1.0.1", + "lodash": "^4.17.4", + "minimatch": "^3.0.3", + "read-pkg-up": "^2.0.0", + "resolve": "^1.6.0" + }, + "dependencies": { + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", + "requires": { + "esutils": "^2.0.2", + "isarray": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + } + } + }, + "eslint-plugin-node": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-5.2.1.tgz", + "integrity": "sha512-xhPXrh0Vl/b7870uEbaumb2Q+LxaEcOQ3kS1jtIXanBAwpMre1l5q/l2l/hESYJGEFKuI78bp6Uw50hlpr7B+g==", + "requires": { + "ignore": "^3.3.6", + "minimatch": "^3.0.4", + "resolve": "^1.3.3", + "semver": "5.3.0" + }, + "dependencies": { + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=" + } + } + }, + "eslint-plugin-promise": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-3.8.0.tgz", + "integrity": "sha512-JiFL9UFR15NKpHyGii1ZcvmtIqa3UTwiDAGb8atSffe43qJ3+1czVGN6UtkklpcJ2DVnqvTMzEKRaJdBkAL2aQ==" + }, + "eslint-plugin-standard": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-3.1.0.tgz", + "integrity": "sha512-fVcdyuKRr0EZ4fjWl3c+gp1BANFJD1+RaWa2UPYfMZ6jCtp5RG00kSaXnK/dE5sYzt4kaWJ9qdxqUfc0d9kX0w==" + }, + "eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==", + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==" + }, + "espree": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "requires": { + "acorn": "^5.5.0", + "acorn-jsx": "^3.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=" + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "events": { + "version": "1.1.1", + "resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=" + }, + "express": { + "version": "4.16.3", + "resolved": "http://registry.npmjs.org/express/-/express-4.16.3.tgz", + "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.2", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.3", + "qs": "6.5.1", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.1", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "external-editor": { + "version": "2.2.0", + "resolved": "http://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "requires": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "falafel": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.1.0.tgz", + "integrity": "sha1-lrsXdh2rqU9G0AFzizzt86Z/4Gw=", + "requires": { + "acorn": "^5.0.0", + "foreach": "^2.0.5", + "isarray": "0.0.1", + "object-keys": "^1.0.6" + } + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "requires": { + "flat-cache": "^1.2.1", + "object-assign": "^4.0.1" + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + } + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "requires": { + "circular-json": "^0.3.1", + "del": "^2.0.2", + "graceful-fs": "^4.1.2", + "write": "^0.2.1" + } + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + }, + "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "http://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "~1.0.0" + } + } + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fstream": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" + }, + "get-assigned-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", + "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==" + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "requires": { + "ini": "^1.3.4" + } + }, + "globals": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz", + "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==" + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + } + } + }, + "got": { + "version": "6.7.1", + "resolved": "http://registry.npmjs.org/got/-/got-6.7.1.tgz", + "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", + "requires": { + "create-error-class": "^3.0.0", + "duplexer3": "^0.1.4", + "get-stream": "^3.0.0", + "is-redirect": "^1.0.0", + "is-retry-allowed": "^1.0.0", + "is-stream": "^1.0.0", + "lowercase-keys": "^1.0.0", + "safe-buffer": "^5.0.1", + "timed-out": "^4.0.0", + "unzip-response": "^2.0.1", + "url-parse-lax": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" + }, + "handlebars": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.12.tgz", + "integrity": "sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA==", + "requires": { + "async": "^2.5.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.0.tgz", + "integrity": "sha512-+qnmNjI4OfH2ipQ9VQOw23bBd/ibtfbVdK2fYbY4acTDqKTW/YDp9McimZdDbG8iV9fZizUqQMD5xvriB146TA==", + "requires": { + "ajv": "^5.3.0", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.5.tgz", + "integrity": "sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA==", + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "hosted-git-info": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", + "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==" + }, + "htmlescape": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", + "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=" + }, + "http-errors": { + "version": "1.6.3", + "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "ieee754": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==" + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==" + }, + "import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=" + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, + "init-package-json": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/init-package-json/-/init-package-json-1.10.3.tgz", + "integrity": "sha512-zKSiXKhQveNteyhcj1CoOP8tqp1QuxPIPBl8Bid99DGLFqA1p87M6lNgfjJHSBoWJJlidGOv5rWjyYKEB3g2Jw==", + "requires": { + "glob": "^7.1.1", + "npm-package-arg": "^4.0.0 || ^5.0.0 || ^6.0.0", + "promzard": "^0.3.0", + "read": "~1.0.1", + "read-package-json": "1 || 2", + "semver": "2.x || 3.x || 4 || 5", + "validate-npm-package-license": "^3.0.1", + "validate-npm-package-name": "^3.0.0" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "inline-source-map": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", + "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", + "requires": { + "source-map": "~0.5.3" + } + }, + "inquirer": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", + "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==", + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.0.4", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rx-lite": "^4.0.8", + "rx-lite-aggregates": "^4.0.8", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "insert-module-globals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.0.tgz", + "integrity": "sha512-VE6NlW+WGn2/AeOMd496AHFYmE7eLKkUY6Ty31k4og5vmA3Fjuwe9v6ifH6Xx/Hz27QvdoMoviw1/pqWRB09Sw==", + "requires": { + "JSONStream": "^1.0.3", + "acorn-node": "^1.5.2", + "combine-source-map": "^0.8.0", + "concat-stream": "^1.6.1", + "is-buffer": "^1.1.0", + "path-is-absolute": "^1.0.1", + "process": "~0.11.0", + "through2": "^2.0.0", + "undeclared-identifiers": "^1.1.2", + "xtend": "^4.0.0" + }, + "dependencies": { + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + } + } + }, + "insight": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/insight/-/insight-0.8.4.tgz", + "integrity": "sha1-ZxyvZbR8n+jD0bMgbPRbshG3WIQ=", + "requires": { + "async": "^1.4.2", + "chalk": "^1.0.0", + "configstore": "^1.0.0", + "inquirer": "^0.10.0", + "lodash.debounce": "^3.0.1", + "object-assign": "^4.0.1", + "os-name": "^1.0.0", + "request": "^2.74.0", + "tough-cookie": "^2.0.0", + "uuid": "^3.0.0" + }, + "dependencies": { + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "requires": { + "restore-cursor": "^1.0.1" + } + }, + "cli-width": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-1.1.1.tgz", + "integrity": "sha1-pNKT72frt7iNSk1CwMzwDE0eNm0=" + }, + "configstore": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-1.4.0.tgz", + "integrity": "sha1-w1eB0FAdJowlxUuLF/YkDopPsCE=", + "requires": { + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "object-assign": "^4.0.1", + "os-tmpdir": "^1.0.0", + "osenv": "^0.1.0", + "uuid": "^2.0.1", + "write-file-atomic": "^1.1.2", + "xdg-basedir": "^2.0.0" + }, + "dependencies": { + "uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" + } + } + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "inquirer": { + "version": "0.10.1", + "resolved": "http://registry.npmjs.org/inquirer/-/inquirer-0.10.1.tgz", + "integrity": "sha1-6iXkzmnKFF4FyZ5G3P7AXkASWUo=", + "requires": { + "ansi-escapes": "^1.1.0", + "ansi-regex": "^2.0.0", + "chalk": "^1.0.0", + "cli-cursor": "^1.0.1", + "cli-width": "^1.0.1", + "figures": "^1.3.5", + "lodash": "^3.3.1", + "readline2": "^1.0.1", + "run-async": "^0.1.0", + "rx-lite": "^3.1.2", + "strip-ansi": "^3.0.0", + "through": "^2.3.6" + } + }, + "lodash": { + "version": "3.10.1", + "resolved": "http://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + }, + "onetime": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" + }, + "restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "requires": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + } + }, + "run-async": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", + "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", + "requires": { + "once": "^1.3.0" + } + }, + "rx-lite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", + "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=" + }, + "write-file-atomic": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz", + "integrity": "sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=", + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "slide": "^1.1.5" + } + }, + "xdg-basedir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-2.0.0.tgz", + "integrity": "sha1-7byQPMOF/ARSPZZqM1UEtVBNG9I=", + "requires": { + "os-homedir": "^1.0.0" + } + } + } + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=" + }, + "ipaddr.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", + "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=" + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "http://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "requires": { + "builtin-modules": "^1.0.0" + } + }, + "is-ci": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", + "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", + "requires": { + "ci-info": "^1.5.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "is-git-url": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-git-url/-/is-git-url-1.0.0.tgz", + "integrity": "sha1-U/aEzRQyhbUsMkS05vKCU1J69ms=" + }, + "is-installed-globally": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz", + "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=", + "requires": { + "global-dirs": "^0.1.0", + "is-path-inside": "^1.0.0" + } + }, + "is-npm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz", + "integrity": "sha1-8vtjpl5JBbQGyGBydloaTceTufQ=" + }, + "is-obj": { + "version": "1.0.1", + "resolved": "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=" + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=" + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" + }, + "is-redirect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", + "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=" + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==" + }, + "is-retry-allowed": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz", + "integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=" + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==" + }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=" + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=" + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=" + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "requires": { + "abbrev": "1" + } + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=" + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "jasmine": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-3.2.0.tgz", + "integrity": "sha512-qv6TZ32r+slrQz8fbx2EhGbD9zlJo3NwPrpLK1nE8inILtZO9Fap52pyHk7mNTh4tG50a+1+tOiWVT3jO5I0Sg==", + "requires": { + "glob": "^7.0.6", + "jasmine-core": "~3.2.0" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "jasmine-core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.2.1.tgz", + "integrity": "sha512-pa9tbBWgU0EE4SWgc85T4sa886ufuQdsgruQANhECYjwqgV4z7Vw/499aCaP8ZH79JDS4vhm8doDG9HO4+e4sA==" + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + }, + "js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stable-stringify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", + "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=", + "requires": { + "jsonify": "~0.0.0" + } + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "labeled-stream-splicer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.1.tgz", + "integrity": "sha512-MC94mHZRvJ3LfykJlTUipBqenZz1pacOZEMhhQ8dMGcDHs0SBE5GbsavUXV7YtP3icBW17W0Zy1I0lfASmo9Pg==", + "requires": { + "inherits": "^2.0.1", + "isarray": "^2.0.4", + "stream-splicer": "^2.0.0" + }, + "dependencies": { + "isarray": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.4.tgz", + "integrity": "sha512-GMxXOiUirWg1xTKRipM0Ek07rX+ubx4nNVElTJdNLYmNO/2YrDkgJGw9CljXn+r4EWiDQg/8lsRdHyg2PJuUaA==" + } + } + }, + "latest-version": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", + "integrity": "sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=", + "requires": { + "package-json": "^4.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + } + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + } + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" + }, + "lodash.debounce": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-3.1.1.tgz", + "integrity": "sha1-gSIRw3ipTMKdWqTjNGzwv846ffU=", + "requires": { + "lodash._getnative": "^3.0.0" + } + }, + "lodash.memoize": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", + "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=" + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + }, + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + }, + "dependencies": { + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + } + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "requires": { + "pify": "^3.0.0" + } + }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "mime-db": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz", + "integrity": "sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==" + }, + "mime-types": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.20.tgz", + "integrity": "sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A==", + "requires": { + "mime-db": "~1.36.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + } + } + }, + "module-deps": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-4.1.1.tgz", + "integrity": "sha1-IyFYM/HaE/1gbMuAh7RIUty4If0=", + "requires": { + "JSONStream": "^1.0.3", + "browser-resolve": "^1.7.0", + "cached-path-relative": "^1.0.0", + "concat-stream": "~1.5.0", + "defined": "^1.0.0", + "detective": "^4.0.0", + "duplexer2": "^0.1.2", + "inherits": "^2.0.1", + "parents": "^1.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.1.3", + "stream-combiner2": "^1.1.1", + "subarg": "^1.0.0", + "through2": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=" + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "nopt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "requires": { + "hosted-git-info": "^2.1.4", + "is-builtin-module": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "npm-package-arg": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-6.1.0.tgz", + "integrity": "sha512-zYbhP2k9DbJhA0Z3HKUePUgdB1x7MfIfKssC+WLPFMKTBZKpZh5m13PgexJjCq6KW7j17r0jHWcCpxEqnnncSA==", + "requires": { + "hosted-git-info": "^2.6.0", + "osenv": "^0.1.5", + "semver": "^5.5.0", + "validate-npm-package-name": "^3.0.0" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "requires": { + "path-key": "^2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-keys": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz", + "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "opener": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.1.tgz", + "integrity": "sha512-goYSy5c2UXE4Ra1xixabeVh1guIX/ZV/YokJksb6q2lubWu6UbvPQ20p542/sFIll1nl8JnCyK9oBaOcCWXwvA==" + }, + "opn": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/opn/-/opn-5.4.0.tgz", + "integrity": "sha512-YF9MNdVy/0qvJvDtunAOzFw9iasOQHpVthTCvGzxt61Il64AYSGdK+rYwld7NAfk9qJ7dt+hymBNSc9LNYS+Sw==", + "requires": { + "is-wsl": "^1.1.0" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "os-browserify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.1.2.tgz", + "integrity": "sha1-ScoCk+CxlZCl9d4Qx/JlphfY/lQ=" + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, + "os-name": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-1.0.3.tgz", + "integrity": "sha1-GzefZINa98Wn9JizV8uVIVwVnt8=", + "requires": { + "osx-release": "^1.0.0", + "win-release": "^1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "osx-release": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/osx-release/-/osx-release-1.1.0.tgz", + "integrity": "sha1-8heRGigTaUmvG/kwiyQeJzfTzWw=", + "requires": { + "minimist": "^1.1.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=" + }, + "package-json": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-4.0.1.tgz", + "integrity": "sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=", + "requires": { + "got": "^6.7.1", + "registry-auth-token": "^3.0.1", + "registry-url": "^3.0.3", + "semver": "^5.1.0" + } + }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=" + }, + "parents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", + "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", + "requires": { + "path-platform": "~0.11.15" + } + }, + "parse-asn1": { + "version": "5.1.1", + "resolved": "http://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "requires": { + "error-ex": "^1.2.0" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==" + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "requires": { + "pinkie-promise": "^2.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "path-platform": { + "version": "0.11.15", + "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", + "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "requires": { + "pify": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + } + } + }, + "pbkdf2": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", + "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "pegjs": { + "version": "0.10.0", + "resolved": "http://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", + "integrity": "sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0=" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz", + "integrity": "sha1-ektQio1bstYp1EcFb/TpyTFM89Q=", + "requires": { + "find-up": "^1.0.0" + } + }, + "plist": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-2.1.0.tgz", + "integrity": "sha1-V8zbeggh3yGDEhejytVOPhRqECU=", + "requires": { + "base64-js": "1.2.0", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + }, + "pluralize": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", + "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==" + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" + }, + "prepend-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", + "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "progress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz", + "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=" + }, + "promzard": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz", + "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", + "requires": { + "read": "1" + } + }, + "properties-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/properties-parser/-/properties-parser-0.3.1.tgz", + "integrity": "sha1-ExbpU5/7/ZOEXjabIRAiq9R4dxo=", + "requires": { + "string.prototype.codepointat": "^0.2.0" + } + }, + "proxy-addr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", + "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.8.0" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, + "psl": { + "version": "1.1.29", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.29.tgz", + "integrity": "sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==" + }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=" + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + }, + "dependencies": { + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": ">= 1.3.1 < 2" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + } + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "requires": { + "mute-stream": "~0.0.4" + } + }, + "read-chunk": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-2.1.0.tgz", + "integrity": "sha1-agTAkoAF7Z1C4aasVgDhnLx/9lU=", + "requires": { + "pify": "^3.0.0", + "safe-buffer": "^5.1.1" + } + }, + "read-only-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", + "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=", + "requires": { + "readable-stream": "^2.0.2" + } + }, + "read-package-json": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-2.0.13.tgz", + "integrity": "sha512-/1dZ7TRZvGrYqE0UAfN6qQb5GYBsNcqS1C0tNK601CFOJmtHI7NIGXwetEPU/OtoFHZL3hDxm4rolFFVE9Bnmg==", + "requires": { + "glob": "^7.1.1", + "graceful-fs": "^4.1.2", + "json-parse-better-errors": "^1.0.1", + "normalize-package-data": "^2.0.0", + "slash": "^1.0.0" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + }, + "dependencies": { + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "requires": { + "locate-path": "^2.0.0" + } + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "readline2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", + "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "mute-stream": "0.0.5" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "mute-stream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", + "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=" + } + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "requires": { + "resolve": "^1.1.6" + } + }, + "regexpp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", + "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==" + }, + "registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "requires": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha1-PU74cPc93h138M+aOBQyRE4XSUI=", + "requires": { + "rc": "^1.0.1" + } + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + } + } + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "requires": { + "caller-path": "^0.1.0", + "resolve-from": "^1.0.0" + } + }, + "resolve": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", + "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=" + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "rewire": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rewire/-/rewire-4.0.1.tgz", + "integrity": "sha512-+7RQ/BYwTieHVXetpKhT11UbfF6v1kGhKFrtZN7UDL2PybMsSt/rpLWeEUGF5Ndsl1D5BxiCB14VDJyoX+noYw==", + "requires": { + "eslint": "^4.19.1" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "requires": { + "glob": "^7.0.5" + }, + "dependencies": { + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "requires": { + "is-promise": "^2.1.0" + } + }, + "rx-lite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", + "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=" + }, + "rx-lite-aggregates": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", + "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "requires": { + "rx-lite": "*" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sax": { + "version": "0.3.5", + "resolved": "http://registry.npmjs.org/sax/-/sax-0.3.5.tgz", + "integrity": "sha1-iPz8H3PAyLvVt8d2ttPzUB7tBz0=" + }, + "semver": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", + "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==" + }, + "semver-diff": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz", + "integrity": "sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=", + "requires": { + "semver": "^5.0.3" + } + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "sha.js": { + "version": "2.4.11", + "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shasum": { + "version": "1.0.2", + "resolved": "http://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", + "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=", + "requires": { + "json-stable-stringify": "~0.0.0", + "sha.js": "~2.4.4" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" + }, + "shell-quote": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", + "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "requires": { + "array-filter": "~0.0.0", + "array-map": "~0.0.0", + "array-reduce": "~0.0.0", + "jsonify": "~0.0.0" + } + }, + "shelljs": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.5.3.tgz", + "integrity": "sha1-xUmCuZbHbvDB5rWfvcWCX1txMRM=" + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "simple-concat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz", + "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=" + }, + "simple-plist": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-0.2.1.tgz", + "integrity": "sha1-cXZts1IyaSjPOoByQrp2IyJjZyM=", + "requires": { + "bplist-creator": "0.0.7", + "bplist-parser": "0.1.1", + "plist": "2.0.1" + }, + "dependencies": { + "base64-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.1.2.tgz", + "integrity": "sha1-1kAMrBxMZgl22Q0HoENR2JOV9eg=" + }, + "plist": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-2.0.1.tgz", + "integrity": "sha1-CjLKlIGxw2TpLhjcVch23p0B2os=", + "requires": { + "base64-js": "1.1.2", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + } + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=" + }, + "slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "requires": { + "is-fullwidth-code-point": "^2.0.0" + } + }, + "slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "spdx-correct": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", + "integrity": "sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g==", + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz", + "integrity": "sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg==" + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz", + "integrity": "sha512-TfOfPcYGBB5sDuPn3deByxPhmfegAhpDYKSOXZQN81Oyrrif8ZCodOLzK3AesELnCx03kikhyDwh0pfvvQvF8w==" + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + }, + "stream-browserify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ=" + }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "requires": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "stream-splicer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.0.tgz", + "integrity": "sha1-G2O+Q4oTPktnHMGTUZdgAXWRDYM=", + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=" + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + }, + "subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", + "requires": { + "minimist": "^1.1.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + }, + "syntax-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", + "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", + "requires": { + "acorn-node": "^1.2.0" + } + }, + "table": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz", + "integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==", + "requires": { + "ajv": "^5.2.3", + "ajv-keywords": "^2.1.0", + "chalk": "^2.1.0", + "lodash": "^4.17.4", + "slice-ansi": "1.0.0", + "string-width": "^2.1.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "tar": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "requires": { + "block-stream": "*", + "fstream": "^1.0.2", + "inherits": "2" + } + }, + "term-size": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz", + "integrity": "sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=", + "requires": { + "execa": "^0.7.0" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" + }, + "through": { + "version": "2.3.8", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "timed-out": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", + "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=" + }, + "timers-browserify": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", + "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=", + "requires": { + "process": "~0.11.0" + } + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=" + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + } + }, + "tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "uglify-js": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", + "integrity": "sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q==", + "optional": true, + "requires": { + "commander": "~2.17.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + } + } + }, + "umd": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", + "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==" + }, + "undeclared-identifiers": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.2.tgz", + "integrity": "sha512-13EaeocO4edF/3JKime9rD7oB6QI8llAGhgn5fKOPyfkJbRb6NFv9pYV6dFEmpa4uRjKeBqLZP8GpuzqHlKDMQ==", + "requires": { + "acorn-node": "^1.3.0", + "get-assigned-identifiers": "^1.2.0", + "simple-concat": "^1.0.0", + "xtend": "^4.0.1" + } + }, + "underscore": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", + "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==" + }, + "unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "requires": { + "crypto-random-string": "^1.0.0" + } + }, + "unorm": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.4.1.tgz", + "integrity": "sha1-NkIA1fE2RsqLzURJAnEzVhR5IwA=" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unzip-response": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz", + "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=" + }, + "update-notifier": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-2.5.0.tgz", + "integrity": "sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==", + "requires": { + "boxen": "^1.2.1", + "chalk": "^2.0.1", + "configstore": "^3.0.0", + "import-lazy": "^2.1.0", + "is-ci": "^1.0.10", + "is-installed-globally": "^0.1.0", + "is-npm": "^1.0.0", + "latest-version": "^3.0.0", + "semver-diff": "^2.0.0", + "xdg-basedir": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, + "url-parse-lax": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", + "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=", + "requires": { + "prepend-http": "^1.0.1" + } + }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "requires": { + "inherits": "2.0.3" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "valid-identifier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/valid-identifier/-/valid-identifier-0.0.1.tgz", + "integrity": "sha1-7x1wk6nTKH4/zpLfkW+GFrI/kLQ=" + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha1-X6kS2B630MdK/BQN5zF/DKffQ34=", + "requires": { + "builtins": "^1.0.3" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "requires": { + "indexof": "0.0.1" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "requires": { + "isexe": "^2.0.0" + } + }, + "widest-line": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.0.tgz", + "integrity": "sha1-AUKk6KJD+IgsAjOqDgKBqnYVInM=", + "requires": { + "string-width": "^2.1.1" + } + }, + "win-release": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/win-release/-/win-release-1.1.1.tgz", + "integrity": "sha1-X6VeAr58qTTt/BJmVjLoSbcuUgk=", + "requires": { + "semver": "^5.0.1" + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "requires": { + "mkdirp": "^0.5.1" + } + }, + "write-file-atomic": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.3.0.tgz", + "integrity": "sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA==", + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "xcode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-1.0.0.tgz", + "integrity": "sha1-4fWxRDJF3tOMGAeW3xoQ/e2ghOw=", + "requires": { + "pegjs": "^0.10.0", + "simple-plist": "^0.2.1", + "uuid": "3.0.1" + }, + "dependencies": { + "uuid": { + "version": "3.0.1", + "resolved": "http://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", + "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=" + } + } + }, + "xdg-basedir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", + "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=" + }, + "xmlbuilder": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", + "integrity": "sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=" + }, + "xmldom": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", + "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + } + } + }, + "cordova-android": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-7.1.2.tgz", + "integrity": "sha512-w28HJGtfAZCT96hVH9BMppWMnmDTZplKu2NRQZN2dCr5e9r7aHpay41MYy9IBkh8+7E7lMo/jZkRwBDNr4VnEg==", + "requires": { + "abbrev": "*", + "android-versions": "1.3.0", + "ansi": "*", + "balanced-match": "*", + "base64-js": "1.2.0", + "big-integer": "*", + "bplist-parser": "*", + "brace-expansion": "*", + "concat-map": "*", + "cordova-common": "2.2.5", + "cordova-registry-mapper": "*", + "elementtree": "0.1.6", + "glob": "5.0.15", + "inflight": "*", + "inherits": "*", + "minimatch": "*", + "nopt": "3.0.1", + "once": "*", + "path-is-absolute": "*", + "plist": "2.1.0", + "properties-parser": "0.2.3", + "q": "1.4.1", + "sax": "0.3.5", + "semver": "*", + "shelljs": "0.5.3", + "underscore": "*", + "unorm": "*", + "wrappy": "*", + "xmlbuilder": "8.2.2", + "xmldom": "*" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true + }, + "android-versions": { + "version": "1.3.0", + "bundled": true, + "requires": { + "semver": "^5.4.1" + } + }, + "ansi": { + "version": "0.3.1", + "bundled": true + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true + }, + "base64-js": { + "version": "1.2.0", + "bundled": true + }, + "big-integer": { + "version": "1.6.32", + "bundled": true + }, + "bplist-parser": { + "version": "0.1.1", + "bundled": true, + "requires": { + "big-integer": "^1.6.7" + } + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true + }, + "cordova-common": { + "version": "2.2.5", + "bundled": true, + "requires": { + "ansi": "^0.3.1", + "bplist-parser": "^0.1.0", + "cordova-registry-mapper": "^1.1.8", + "elementtree": "0.1.6", + "glob": "^5.0.13", + "minimatch": "^3.0.0", + "plist": "^2.1.0", + "q": "^1.4.1", + "shelljs": "^0.5.3", + "underscore": "^1.8.3", + "unorm": "^1.3.3" + } + }, + "cordova-registry-mapper": { + "version": "1.1.15", + "bundled": true + }, + "elementtree": { + "version": "0.1.6", + "bundled": true, + "requires": { + "sax": "0.3.5" + } + }, + "glob": { + "version": "5.0.15", + "bundled": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "nopt": { + "version": "3.0.1", + "bundled": true, + "requires": { + "abbrev": "1" + } + }, + "once": { + "version": "1.4.0", + "bundled": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true + }, + "plist": { + "version": "2.1.0", + "bundled": true, + "requires": { + "base64-js": "1.2.0", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + }, + "properties-parser": { + "version": "0.2.3", + "bundled": true + }, + "q": { + "version": "1.4.1", + "bundled": true + }, + "sax": { + "version": "0.3.5", + "bundled": true + }, + "semver": { + "version": "5.5.0", + "bundled": true + }, + "shelljs": { + "version": "0.5.3", + "bundled": true + }, + "underscore": { + "version": "1.9.1", + "bundled": true + }, + "unorm": { + "version": "1.4.1", + "bundled": true + }, + "wrappy": { + "version": "1.0.2", + "bundled": true + }, + "xmlbuilder": { + "version": "8.2.2", + "bundled": true + }, + "xmldom": { + "version": "0.1.27", + "bundled": true + } + } + }, + "cordova-android-support-gradle-release": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cordova-android-support-gradle-release/-/cordova-android-support-gradle-release-3.0.1.tgz", + "integrity": "sha512-RSW55DkSckmqhX/kjj+a1YeVdy7s/AtlZn6Qa5XMQmmA4Iogq+IF2jvInZqzCF19DbI5YE95AP7VDbRk+DdDRw==", + "requires": { + "q": "^1.4.1", + "semver": "5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" + } + } + }, + "cordova-clipboard": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/cordova-clipboard/-/cordova-clipboard-1.3.0.tgz", + "integrity": "sha512-IGk4LZm/DJ0Xk/jgakHm4wa+A/lrRP3QfzMAHDG7oWLJS4ISOpfI32Wez4ndnENItRslGyBVyJyKD83CxELCAw==" + }, + "cordova-ios": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/cordova-ios/-/cordova-ios-4.5.5.tgz", + "integrity": "sha512-3+30m2dZ2yii7kg+H7cgpdpkXpMj54zoX5imjGGG4Z7dPXKmalTLc/9rLq+Iaa+Q1BqyOrUFaHopWOODRU6vCg==", + "requires": { + "abbrev": "*", + "ansi": "*", + "balanced-match": "*", + "base64-js": "1.2.0", + "big-integer": "*", + "bplist-creator": "*", + "bplist-parser": "*", + "brace-expansion": "*", + "concat-map": "*", + "cordova-common": "2.2.5", + "cordova-registry-mapper": "*", + "elementtree": "0.1.6", + "glob": "5.0.15", + "inflight": "*", + "inherits": "*", + "ios-sim": "6.1.3", + "minimatch": "*", + "nopt": "3.0.6", + "once": "*", + "path-is-absolute": "*", + "plist": "2.1.0", + "q": "1.5.1", + "sax": "0.3.5", + "shelljs": "0.5.3", + "simctl": "*", + "simple-plist": "0.2.1", + "stream-buffers": "2.2.0", + "tail": "0.4.0", + "underscore": "*", + "unorm": "*", + "uuid": "3.0.1", + "wrappy": "*", + "xcode": "0.9.3", + "xml-escape": "1.1.0", + "xmlbuilder": "8.2.2", + "xmldom": "*" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true + }, + "ansi": { + "version": "0.3.1", + "bundled": true + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true + }, + "base64-js": { + "version": "1.2.0", + "bundled": true + }, + "big-integer": { + "version": "1.6.32", + "bundled": true + }, + "bplist-creator": { + "version": "0.0.7", + "bundled": true, + "requires": { + "stream-buffers": "~2.2.0" + } + }, + "bplist-parser": { + "version": "0.1.1", + "bundled": true, + "requires": { + "big-integer": "^1.6.7" + } + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true + }, + "cordova-common": { + "version": "2.2.5", + "bundled": true, + "requires": { + "ansi": "^0.3.1", + "bplist-parser": "^0.1.0", + "cordova-registry-mapper": "^1.1.8", + "elementtree": "0.1.6", + "glob": "^5.0.13", + "minimatch": "^3.0.0", + "plist": "^2.1.0", + "q": "^1.4.1", + "shelljs": "^0.5.3", + "underscore": "^1.8.3", + "unorm": "^1.3.3" + } + }, + "cordova-registry-mapper": { + "version": "1.1.15", + "bundled": true + }, + "elementtree": { + "version": "0.1.6", + "bundled": true, + "requires": { + "sax": "0.3.5" + } + }, + "glob": { + "version": "5.0.15", + "bundled": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true + }, + "ios-sim": { + "version": "6.1.3", + "bundled": true, + "requires": { + "bplist-parser": "^0.0.6", + "nopt": "1.0.9", + "plist": "^2.1.0", + "simctl": "^1.1.1" + }, + "dependencies": { + "bplist-parser": { + "version": "0.0.6", + "bundled": true + }, + "nopt": { + "version": "1.0.9", + "bundled": true, + "requires": { + "abbrev": "1" + } + } + } + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "nopt": { + "version": "3.0.6", + "bundled": true, + "requires": { + "abbrev": "1" + } + }, + "once": { + "version": "1.4.0", + "bundled": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true + }, + "plist": { + "version": "2.1.0", + "bundled": true, + "requires": { + "base64-js": "1.2.0", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + }, + "q": { + "version": "1.5.1", + "bundled": true + }, + "sax": { + "version": "0.3.5", + "bundled": true + }, + "shelljs": { + "version": "0.5.3", + "bundled": true + }, + "simctl": { + "version": "1.1.1", + "bundled": true, + "requires": { + "shelljs": "^0.2.6", + "tail": "^0.4.0" + }, + "dependencies": { + "shelljs": { + "version": "0.2.6", + "bundled": true + } + } + }, + "simple-plist": { + "version": "0.2.1", + "bundled": true, + "requires": { + "bplist-creator": "0.0.7", + "bplist-parser": "0.1.1", + "plist": "2.0.1" + }, + "dependencies": { + "base64-js": { + "version": "1.1.2", + "bundled": true + }, + "plist": { + "version": "2.0.1", + "bundled": true, + "requires": { + "base64-js": "1.1.2", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + } + } + }, + "stream-buffers": { + "version": "2.2.0", + "bundled": true + }, + "tail": { + "version": "0.4.0", + "bundled": true + }, + "underscore": { + "version": "1.9.1", + "bundled": true + }, + "unorm": { + "version": "1.4.1", + "bundled": true + }, + "uuid": { + "version": "3.0.1", + "bundled": true + }, + "wrappy": { + "version": "1.0.2", + "bundled": true + }, + "xcode": { + "version": "0.9.3", + "bundled": true, + "requires": { + "pegjs": "^0.10.0", + "simple-plist": "^0.2.1", + "uuid": "3.0.1" + } + }, + "xml-escape": { + "version": "1.1.0", + "bundled": true + }, + "xmlbuilder": { + "version": "8.2.2", + "bundled": true + }, + "xmldom": { + "version": "0.1.27", + "bundled": true + } + } + }, + "cordova-plugin-badge": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/cordova-plugin-badge/-/cordova-plugin-badge-0.8.8.tgz", + "integrity": "sha512-RhIBtd5xhD/iLnxjt35jvOae28oNW/wtMZBOmQR3Rf0y4wirvA1bpAZEhBoFqL+rZGhsd6ddOdQXdex1T0DRyQ==" + }, + "cordova-plugin-camera": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cordova-plugin-camera/-/cordova-plugin-camera-4.1.0.tgz", + "integrity": "sha512-fCLhWjWYn49q3X5xaypAPgTz6MAWSKFFQvD2Gpi5SuVlrRPRphtX2jIqR2zCBuDTBR082QVnlc+yUDXt65Mjgw==" + }, + "cordova-plugin-customurlscheme": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cordova-plugin-customurlscheme/-/cordova-plugin-customurlscheme-4.4.0.tgz", + "integrity": "sha512-7VPJnNfvfZQSU1IdhJX7BpDgvC7bEe+Kfg9Cj8guSoZDcTi378qQFb6VOwthT8hwGXx2bZzWf0qnTZdRlLQy+Q==" + }, + "cordova-plugin-device": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/cordova-plugin-device/-/cordova-plugin-device-2.0.3.tgz", + "integrity": "sha512-Jb3V72btxf3XHpkPQsGdyc8N6tVBYn1vsxSFj43fIz9vonJDUThYPCJJHqk6PX6N4dJw6I4FjxkpfCR4LDYMlw==" + }, + "cordova-plugin-file": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/cordova-plugin-file/-/cordova-plugin-file-6.0.2.tgz", + "integrity": "sha512-m7cughw327CjONN/qjzsTpSesLaeybksQh420/gRuSXJX5Zt9NfgsSbqqKDon6jnQ9Mm7h7imgyO2uJ34XMBtA==" + }, + "cordova-plugin-file-opener2": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/cordova-plugin-file-opener2/-/cordova-plugin-file-opener2-2.0.19.tgz", + "integrity": "sha1-yjrhIlOVt3qx/lsgrMv+zGiOJJM=" + }, + "cordova-plugin-file-transfer": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/cordova-plugin-file-transfer/-/cordova-plugin-file-transfer-1.7.1.tgz", + "integrity": "sha1-p12L4uvDu5sjxbG70ZkhTsJnWGs=" + }, + "cordova-plugin-globalization": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/cordova-plugin-globalization/-/cordova-plugin-globalization-1.11.0.tgz", + "integrity": "sha1-6sMVgQAphJOvowvolA5pj2HvvP4=" + }, + "cordova-plugin-inappbrowser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cordova-plugin-inappbrowser/-/cordova-plugin-inappbrowser-3.1.0.tgz", + "integrity": "sha512-YqrZfYgbGTS20SFID0mrRxud312VH072QVlFonCAkPgtGg1Svy7lELOCNFd+KU7t4mVtZeTEjZPEeefvjaetwQ==" + }, + "cordova-plugin-ionic-keyboard": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.1.3.tgz", + "integrity": "sha512-6ucQ6FdlLdBm8kJfFnzozmBTjru/0xekHP/dAhjoCZggkGRlgs8TsUJFkxa/bV+qi7Dlo50JjmpE4UMWAO+aOQ==" + }, + "cordova-plugin-local-notification": { + "version": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#5b2f3073a1c1fb39cad3566be792445c343db2c6", + "from": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle" + }, + "cordova-plugin-media-capture": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz", + "integrity": "sha512-pVQOrNM7VAuVUMXibAlMGIArrftHPrRs4dUCoE+e2HEFUp3LmN3Yj539LjdUxcWmz/A/cHC65m9E3DS56YJhcg==" + }, + "cordova-plugin-network-information": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/cordova-plugin-network-information/-/cordova-plugin-network-information-2.0.2.tgz", + "integrity": "sha512-NwO3qDBNL/vJxUxBTPNOA1HvkDf9eTeGH8JSZiwy1jq2W2mJKQEDBwqWkaEQS19Yd/MQTiw0cykxg5D7u4J6cQ==" + }, + "cordova-plugin-screen-orientation": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/cordova-plugin-screen-orientation/-/cordova-plugin-screen-orientation-3.0.2.tgz", + "integrity": "sha512-2w6CMC+HGvbhogJetalwGurL2Fx8DQCCPy3wlSZHN1/W7WoQ5n9ujVozcoKrY4VaagK6bxrPFih+ElkO8Uqfzg==" + }, + "cordova-plugin-splashscreen": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/cordova-plugin-splashscreen/-/cordova-plugin-splashscreen-5.0.3.tgz", + "integrity": "sha512-rnoDXMDfzoeHDBvsnu6JmzDE/pV5YJCAfc5hYX/Mb2BIXGgSjFJheByt0tU6kp3Wl40tSyFX4pYfBwFblBGyRg==" + }, + "cordova-plugin-statusbar": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/cordova-plugin-statusbar/-/cordova-plugin-statusbar-2.4.3.tgz", + "integrity": "sha512-ThmXzl6QIKWFXf4wWw7Q/zpB+VKkz3VM958+5A0sXD4jmR++u7KnGttLksXshVwWr6lvGwUebLYtIyXwS4Ovcg==" + }, + "cordova-plugin-whitelist": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/cordova-plugin-whitelist/-/cordova-plugin-whitelist-1.3.4.tgz", + "integrity": "sha512-EYC5eQFVkoYXq39l7tYKE6lEjHJ04mvTmKXxGL7quHLdFPfJMNzru/UYpn92AOfpl3PQaZmou78C7EgmFOwFQQ==" + }, + "cordova-plugin-zip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cordova-plugin-zip/-/cordova-plugin-zip-3.1.0.tgz", + "integrity": "sha1-F2yCSOog058c+VnvXmFWrMqWshc=" + }, + "cordova-sqlite-storage": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/cordova-sqlite-storage/-/cordova-sqlite-storage-2.6.0.tgz", + "integrity": "sha512-m+KylFwNRsQloJ6TZihupfma2muXkmNglh8cFSiJQqriTTm6eq0gyPaAuKxgoPswe679G+0aUai07NFC/f0GGQ==", + "requires": { + "cordova-sqlite-storage-dependencies": "1.2.1" + } + }, + "cordova-sqlite-storage-dependencies": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cordova-sqlite-storage-dependencies/-/cordova-sqlite-storage-dependencies-1.2.1.tgz", + "integrity": "sha512-4ihQApBGVKR1QZ4oOSGctKFfthtCfiWMTcIIfxe97vKxlvGr9NyXOvYG9vXU9S7yVR7Ua+Rj47hkE7pQIKvQTg==" + }, + "cordova-support-google-services": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cordova-support-google-services/-/cordova-support-google-services-1.2.1.tgz", + "integrity": "sha512-EnFjKAE9oI2uzyUvEfWpLgTM200nuJVvShaA4yyz9wMKBUN+H/BRG1byd1ibZz3sSihNKi3FxjQPxmmEn6/IfA==" + }, + "core-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.3.0.tgz", + "integrity": "sha1-+rg/uwstjchfpjbEudNMdUIMbWU=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "dev": true, + "requires": { + "es5-ext": "^0.10.9" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + } + } + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "dateformat": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", + "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "default-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", + "integrity": "sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==", + "dev": true, + "requires": { + "kind-of": "^5.0.2" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "default-resolution": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/default-resolution/-/default-resolution-2.0.0.tgz", + "integrity": "sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", + "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", + "requires": { + "foreach": "^2.0.5", + "object-keys": "^1.0.8" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", + "dev": true + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "doctrine": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-0.7.2.tgz", + "integrity": "sha1-fLhgNZujvpDgQLJrcpzkv6ZUxSM=", + "dev": true, + "requires": { + "esutils": "^1.1.6", + "isarray": "0.0.1" + }, + "dependencies": { + "esutils": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.1.6.tgz", + "integrity": "sha1-wBzKqa5LiXxtDD4hCuUvPHqEQ3U=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "dotenv": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz", + "integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==", + "dev": true + }, + "dotenv-defaults": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-1.0.2.tgz", + "integrity": "sha512-iXFvHtXl/hZPiFj++1hBg4lbKwGM+t/GlvELDnRtOFdjXyWP7mubkVr+eZGWG62kdsbulXAef6v/j6kiWc/xGA==", + "dev": true, + "requires": { + "dotenv": "^6.2.0" + } + }, + "dotenv-expand": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-4.2.0.tgz", + "integrity": "sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU=", + "dev": true + }, + "dotenv-webpack": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-1.7.0.tgz", + "integrity": "sha512-wwNtOBW/6gLQSkb8p43y0Wts970A3xtNiG/mpwj9MLUhtPCQG6i+/DSXXoNN7fbPCU/vQ7JjwGmgOeGZSSZnsw==", + "dev": true, + "requires": { + "dotenv-defaults": "^1.0.2" + } + }, + "duplexer2": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", + "integrity": "sha1-xhTc9n4vsUmVqRcR5aYX6KYKMds=", + "dev": true, + "requires": { + "readable-stream": "~1.1.9" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "dev": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "each-props": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", + "integrity": "sha512-vV0Hem3zAGkJAyU7JSjixeU66rwdynTAa1vofCrSA5fEln+m67Az9CcnkVD776/fsN/UjIWmBDoNRS6t6G9RfA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.1", + "object.defaults": "^1.1.0" + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "ejs": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.6.1.tgz", + "integrity": "sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==", + "dev": true + }, + "electron-builder-lib": { + "version": "20.23.1", + "resolved": "https://registry.npmjs.org/electron-builder-lib/-/electron-builder-lib-20.23.1.tgz", + "integrity": "sha512-9bYeANVqFPpSmswPwXv8efu9voPE1Q8hw/jNwiWGICjPeYjHyKwa4ao+Vd1beY6ZhUDVhxxXIdlJWnmvH7Mcxw==", + "dev": true, + "requires": { + "7zip-bin": "~4.0.2", + "app-builder-bin": "1.11.4", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.5", + "builder-util": "5.17.0", + "builder-util-runtime": "4.4.1", + "chromium-pickle-js": "^0.2.0", + "debug": "^3.1.0", + "ejs": "^2.6.1", + "electron-osx-sign": "0.4.10", + "electron-publish": "20.23.1", + "env-paths": "^1.0.0", + "fs-extra-p": "^4.6.1", + "hosted-git-info": "^2.7.1", + "is-ci": "^1.1.0", + "isbinaryfile": "^3.0.2", + "js-yaml": "^3.12.0", + "lazy-val": "^1.0.3", + "minimatch": "^3.0.4", + "normalize-package-data": "^2.4.0", + "plist": "^3.0.1", + "read-config-file": "3.1.0", + "sanitize-filename": "^1.6.1", + "semver": "^5.5.0", + "sumchecker": "^2.0.2", + "temp-file": "^3.1.3" + }, + "dependencies": { + "app-builder-bin": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-1.11.4.tgz", + "integrity": "sha512-04sgoFSz6q5pbAxAXcxfUFPl16gJsay5b8dudFXUwQbFfS7ox2uGgbOO5LGHF0t7sM7q/N82ztGePuuCSkKZHQ==", + "dev": true + }, + "builder-util-runtime": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-4.4.1.tgz", + "integrity": "sha512-8L2pbL6D3VdI1f8OMknlZJpw0c7KK15BRz3cY77AOUElc4XlCv2UhVV01jJM7+6Lx7henaQh80ALULp64eFYAQ==", + "dev": true, + "requires": { + "bluebird-lst": "^1.0.5", + "debug": "^3.1.0", + "fs-extra-p": "^4.6.1", + "sax": "^1.2.4" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "hosted-git-info": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", + "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "electron-osx-sign": { + "version": "0.4.10", + "resolved": "http://registry.npmjs.org/electron-osx-sign/-/electron-osx-sign-0.4.10.tgz", + "integrity": "sha1-vk87ibKnWh3F8eckkIGrKSnKOiY=", + "dev": true, + "requires": { + "bluebird": "^3.5.0", + "compare-version": "^0.1.2", + "debug": "^2.6.8", + "isbinaryfile": "^3.0.2", + "minimist": "^1.2.0", + "plist": "^2.1.0" + }, + "dependencies": { + "base64-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.0.tgz", + "integrity": "sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=", + "dev": true + }, + "plist": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-2.1.0.tgz", + "integrity": "sha1-V8zbeggh3yGDEhejytVOPhRqECU=", + "dev": true, + "requires": { + "base64-js": "1.2.0", + "xmlbuilder": "8.2.2", + "xmldom": "0.1.x" + } + }, + "xmlbuilder": { + "version": "8.2.2", + "resolved": "http://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", + "integrity": "sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=", + "dev": true + } + } + }, + "electron-publish": { + "version": "20.23.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-20.23.1.tgz", + "integrity": "sha512-FsNL5bY4/Uab5YGYWE+/7wL8znkghEPwJvDyD0985Obw0Eg9JZP1MpUbt4jUxrK8z5wTLVMFdSv7ymV8yq6ypA==", + "dev": true, + "requires": { + "bluebird-lst": "^1.0.5", + "builder-util": "~5.17.0", + "builder-util-runtime": "^4.4.1", + "chalk": "^2.4.1", + "fs-extra-p": "^4.6.1", + "lazy-val": "^1.0.3", + "mime": "^2.3.1" + }, + "dependencies": { + "builder-util-runtime": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-4.4.1.tgz", + "integrity": "sha512-8L2pbL6D3VdI1f8OMknlZJpw0c7KK15BRz3cY77AOUElc4XlCv2UhVV01jJM7+6Lx7henaQh80ALULp64eFYAQ==", + "dev": true, + "requires": { + "bluebird-lst": "^1.0.5", + "debug": "^3.1.0", + "fs-extra-p": "^4.6.1", + "sax": "^1.2.4" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "mime": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz", + "integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==", + "dev": true + }, + "ms": { + "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true @@ -4241,9 +8895,9 @@ } }, "extend": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", - "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, "extend-shallow": { @@ -4356,13 +9010,13 @@ } }, "findup-sync": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", - "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz", + "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==", "dev": true, "requires": { "detect-file": "^1.0.0", - "is-glob": "^3.1.0", + "is-glob": "^4.0.0", "micromatch": "^3.0.4", "resolve-dir": "^1.0.1" }, @@ -4595,12 +9249,12 @@ "dev": true }, "is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", "dev": true, "requires": { - "is-extglob": "^2.1.0" + "is-extglob": "^2.1.1" } }, "is-number": { @@ -4659,9 +9313,9 @@ } }, "fined": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-1.1.0.tgz", - "integrity": "sha1-s33IRLdqL15wgeiE98CuNE8VNHY=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", + "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", "dev": true, "requires": { "expand-tilde": "^2.0.2", @@ -4672,19 +9326,19 @@ } }, "flagged-respawn": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.0.tgz", - "integrity": "sha1-Tnmumy6zi/hrO7Vr8+ClaqX8q9c=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", + "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", "dev": true }, "flush-write-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", - "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", "dev": true, "requires": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.4" + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" } }, "font-awesome": { @@ -4799,13 +9453,13 @@ "dev": true }, "fsevents": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", - "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", + "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", "optional": true, "requires": { - "nan": "^2.9.2", - "node-pre-gyp": "^0.10.0" + "nan": "^2.12.1", + "node-pre-gyp": "^0.12.0" }, "dependencies": { "abbrev": { @@ -4823,7 +9477,7 @@ "optional": true }, "are-we-there-yet": { - "version": "1.1.4", + "version": "1.1.5", "bundled": true, "optional": true, "requires": { @@ -4844,7 +9498,7 @@ } }, "chownr": { - "version": "1.0.1", + "version": "1.1.1", "bundled": true, "optional": true }, @@ -4866,15 +9520,15 @@ "optional": true }, "debug": { - "version": "2.6.9", + "version": "4.1.1", "bundled": true, "optional": true, "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "deep-extend": { - "version": "0.5.1", + "version": "0.6.0", "bundled": true, "optional": true }, @@ -4917,7 +9571,7 @@ } }, "glob": { - "version": "7.1.2", + "version": "7.1.3", "bundled": true, "optional": true, "requires": { @@ -4935,11 +9589,11 @@ "optional": true }, "iconv-lite": { - "version": "0.4.21", + "version": "0.4.24", "bundled": true, "optional": true, "requires": { - "safer-buffer": "^2.1.0" + "safer-buffer": ">= 2.1.2 < 3" } }, "ignore-walk": { @@ -4992,15 +9646,15 @@ "bundled": true }, "minipass": { - "version": "2.2.4", + "version": "2.3.5", "bundled": true, "requires": { - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "minizlib": { - "version": "1.1.0", + "version": "1.2.1", "bundled": true, "optional": true, "requires": { @@ -5015,32 +9669,38 @@ } }, "ms": { - "version": "2.0.0", + "version": "2.1.1", "bundled": true, "optional": true }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "optional": true + }, "needle": { - "version": "2.2.0", + "version": "2.3.0", "bundled": true, "optional": true, "requires": { - "debug": "^2.1.2", + "debug": "^4.1.0", "iconv-lite": "^0.4.4", "sax": "^1.2.4" } }, "node-pre-gyp": { - "version": "0.10.0", + "version": "0.12.0", "bundled": true, "optional": true, "requires": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", - "needle": "^2.2.0", + "needle": "^2.2.1", "nopt": "^4.0.1", "npm-packlist": "^1.1.6", "npmlog": "^4.0.2", - "rc": "^1.1.7", + "rc": "^1.2.7", "rimraf": "^2.6.1", "semver": "^5.3.0", "tar": "^4" @@ -5056,12 +9716,12 @@ } }, "npm-bundled": { - "version": "1.0.3", + "version": "1.0.6", "bundled": true, "optional": true }, "npm-packlist": { - "version": "1.1.10", + "version": "1.4.1", "bundled": true, "optional": true, "requires": { @@ -5126,11 +9786,11 @@ "optional": true }, "rc": { - "version": "1.2.7", + "version": "1.2.8", "bundled": true, "optional": true, "requires": { - "deep-extend": "^0.5.1", + "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" @@ -5158,15 +9818,15 @@ } }, "rimraf": { - "version": "2.6.2", + "version": "2.6.3", "bundled": true, "optional": true, "requires": { - "glob": "^7.0.5" + "glob": "^7.1.3" } }, "safe-buffer": { - "version": "5.1.1", + "version": "5.1.2", "bundled": true }, "safer-buffer": { @@ -5180,7 +9840,7 @@ "optional": true }, "semver": { - "version": "5.5.0", + "version": "5.7.0", "bundled": true, "optional": true }, @@ -5224,16 +9884,16 @@ "optional": true }, "tar": { - "version": "4.4.1", + "version": "4.4.8", "bundled": true, "optional": true, "requires": { - "chownr": "^1.0.1", + "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.2.4", - "minizlib": "^1.1.0", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.2" } }, @@ -5243,11 +9903,11 @@ "optional": true }, "wide-align": { - "version": "1.1.2", + "version": "1.1.3", "bundled": true, "optional": true, "requires": { - "string-width": "^1.0.2" + "string-width": "^1.0.2 || 2" } }, "wrappy": { @@ -5255,7 +9915,7 @@ "bundled": true }, "yallist": { - "version": "3.0.2", + "version": "3.0.3", "bundled": true } } @@ -5420,13 +10080,15 @@ } }, "glob-watcher": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.1.tgz", - "integrity": "sha512-fK92r2COMC199WCyGUblrZKhjra3cyVMDiypDdqg1vsSDmexnbYivK1kNR4QItiNXLKmGlqan469ks67RtNa2g==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.3.tgz", + "integrity": "sha512-8tWsULNEPHKQ2MR4zXuzSmqbdyV5PtwwCaWSGQ1WwHsJ07ilNeN1JB8ntxhckbnpSHaf9dXFUHzIWvm1I13dsg==", "dev": true, "requires": { + "anymatch": "^2.0.0", "async-done": "^1.2.0", "chokidar": "^2.0.0", + "is-negated-glob": "^1.0.0", "just-debounce": "^1.0.0", "object.defaults": "^1.1.0" }, @@ -5459,17 +10121,196 @@ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", "dev": true, "requires": { - "arr-flatten": "^1.1.0", + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "chokidar": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.6.tgz", + "integrity": "sha512-V2jUo67OKkc6ySiRpJrjlpJKl9kDuG+Xb8VgsGzb+aEouhgS1D0weyPU4lEzdAcsCAvrih2J2BqyXqHWvVLw5g==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + } + }, + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", "to-regex": "^3.0.1" }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, "dependencies": { "extend-shallow": { "version": "2.0.1", @@ -5477,180 +10318,537 @@ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", "dev": true, "requires": { - "is-extendable": "^0.1.0" + "is-extendable": "^0.1.0" + } + } + } + }, + "fsevents": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", + "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.12.1", + "node-pre-gyp": "^0.12.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" } - } - } - }, - "chokidar": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", - "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", - "dev": true, - "requires": { - "anymatch": "^2.0.0", - "async-each": "^1.0.0", - "braces": "^2.3.0", - "fsevents": "^1.2.2", - "glob-parent": "^3.1.0", - "inherits": "^2.0.1", - "is-binary-path": "^1.0.0", - "is-glob": "^4.0.0", - "lodash.debounce": "^4.0.8", - "normalize-path": "^2.1.1", - "path-is-absolute": "^1.0.0", - "readdirp": "^2.0.0", - "upath": "^1.0.5" - } - }, - "expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", - "dev": true, - "requires": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.3.5", + "bundled": true, "dev": true, "requires": { - "is-descriptor": "^0.1.0" + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.3.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^4.1.0", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.12.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" } }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.7.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } + "optional": true }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "signal-exit": { + "version": "3.0.2", + "bundled": true, "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } + "optional": true }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "string-width": { + "version": "1.0.2", + "bundled": true, "dev": true, "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" } }, - "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "string_decoder": { + "version": "1.1.1", + "bundled": true, "dev": true, + "optional": true, "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "safe-buffer": "~5.1.0" } }, - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, - "extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "requires": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "strip-ansi": { + "version": "3.0.1", + "bundled": true, "dev": true, "requires": { - "is-descriptor": "^1.0.0" + "ansi-regex": "^2.0.0" } }, - "extend-shallow": { + "strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.8", + "bundled": true, "dev": true, + "optional": true, "requires": { - "is-extendable": "^0.1.0" + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" } - } - } - }, - "fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, "dev": true, + "optional": true, "requires": { - "is-extendable": "^0.1.0" + "string-width": "^1.0.2 || 2" } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.0.3", + "bundled": true, + "dev": true } } }, @@ -5711,9 +10909,9 @@ "dev": true }, "is-glob": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", - "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", "dev": true, "requires": { "is-extglob": "^2.1.1" @@ -5771,6 +10969,30 @@ "snapdragon": "^0.8.1", "to-regex": "^3.0.2" } + }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "dev": true, + "optional": true + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "upath": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.2.tgz", + "integrity": "sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q==", + "dev": true } } }, @@ -5824,21 +11046,21 @@ "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" }, "gulp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.0.tgz", - "integrity": "sha1-lXZsYB2t5Kd+0+eyttwDiBtZY2Y=", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", + "integrity": "sha512-dvEs27SCZt2ibF29xYgmnwwCYZxdxhQ/+LFWlbAW8y7jt68L/65402Lz3+CKy0Ov4rOs+NERmDq7YlZaDqUIfA==", "dev": true, "requires": { - "glob-watcher": "^5.0.0", - "gulp-cli": "^2.0.0", - "undertaker": "^1.0.0", + "glob-watcher": "^5.0.3", + "gulp-cli": "^2.2.0", + "undertaker": "^1.2.1", "vinyl-fs": "^3.0.0" }, "dependencies": { "gulp-cli": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-2.0.1.tgz", - "integrity": "sha512-RxujJJdN8/O6IW2nPugl7YazhmrIEjmiVfPKrWt68r71UCaLKS71Hp0gpKT+F6qOUFtr7KqtifDKaAJPRVvMYQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-2.2.0.tgz", + "integrity": "sha512-rGs3bVYHdyJpLqR0TUBnlcZ1O5O++Zs4bA0ajm+zr3WFCfiSLjGwoCBqFs18wzN+ZxahT9DkOK5nDf26iDsWjA==", "dev": true, "requires": { "ansi-colors": "^1.0.1", @@ -5851,7 +11073,7 @@ "gulplog": "^1.0.0", "interpret": "^1.1.0", "isobject": "^3.0.1", - "liftoff": "^2.5.0", + "liftoff": "^3.1.0", "matchdep": "^2.0.0", "mute-stdout": "^1.0.0", "pretty-hrtime": "^1.0.0", @@ -6232,9 +11454,9 @@ } }, "homedir-polyfill": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz", - "integrity": "sha1-TCu8inWJmP7r9e1oWA921GdotLw=", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", "dev": true, "requires": { "parse-passwd": "^1.0.0" @@ -6725,14 +11947,11 @@ "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", "dev": true }, - "json-stable-stringify": { + "json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", - "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", - "dev": true, - "requires": { - "jsonify": "~0.0.0" - } + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true }, "json-stringify-safe": { "version": "5.0.1", @@ -6758,12 +11977,6 @@ "graceful-fs": "^4.1.6" } }, - "jsonify": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", - "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", - "dev": true - }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -6893,13 +12106,13 @@ } }, "liftoff": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.5.0.tgz", - "integrity": "sha1-IAkpG7Mc6oYbvxCnwVooyvdcMew=", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", + "integrity": "sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==", "dev": true, "requires": { "extend": "^3.0.0", - "findup-sync": "^2.0.0", + "findup-sync": "^3.0.0", "fined": "^1.0.1", "flagged-respawn": "^1.0.0", "is-plain-object": "^2.0.4", @@ -6963,9 +12176,9 @@ } }, "lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", "dev": true }, "lodash._basecopy": { @@ -7034,12 +12247,6 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, - "lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", - "dev": true - }, "lodash.escape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz", @@ -7073,9 +12280,9 @@ } }, "lodash.mergewith": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", - "integrity": "sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true }, "lodash.restparam": { @@ -7403,6 +12610,18 @@ } } }, + "findup-sync": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", + "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=", + "dev": true, + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^3.1.0", + "micromatch": "^3.0.4", + "resolve-dir": "^1.0.1" + } + }, "is-accessor-descriptor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", @@ -7432,6 +12651,21 @@ "kind-of": "^6.0.2" } }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + }, "is-number": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", @@ -7641,9 +12875,9 @@ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" }, "mixin-deep": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", - "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", "dev": true, "requires": { "for-in": "^1.0.2", @@ -7705,7 +12939,8 @@ "nan": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", - "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==" + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "dev": true }, "nanomatch": { "version": "1.2.9", @@ -8086,9 +13321,9 @@ "dev": true }, "now-and-later": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.0.tgz", - "integrity": "sha1-vGHLtFbXnLMiB85HygUTb/Ln1u4=", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", + "integrity": "sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==", "dev": true, "requires": { "once": "^1.3.2" @@ -9701,9 +14936,9 @@ "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=" }, "set-value": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", - "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", "dev": true, "requires": { "extend-shallow": "^2.0.1", @@ -10257,9 +15492,9 @@ } }, "through2-filter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-2.0.0.tgz", - "integrity": "sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", + "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", "dev": true, "requires": { "through2": "~2.0.0", @@ -10623,9 +15858,9 @@ "dev": true }, "undertaker": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.2.0.tgz", - "integrity": "sha1-M52kZGJS0ILcN45wgGcpl1DhG0k=", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.2.1.tgz", + "integrity": "sha512-71WxIzDkgYk9ZS+spIB8iZXchFhAdEo2YU8xYqBYJ39DIUIqziK78ftm26eecoIY49X0J2MLhG4hr18Yp6/CMA==", "dev": true, "requires": { "arr-flatten": "^1.0.1", @@ -10646,48 +15881,25 @@ "dev": true }, "union-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", - "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dev": true, "requires": { "arr-union": "^3.1.0", "get-value": "^2.0.6", "is-extendable": "^0.1.1", - "set-value": "^0.4.3" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "set-value": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", - "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.1", - "to-object-path": "^0.3.0" - } - } + "set-value": "^2.0.1" } }, "unique-stream": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.2.1.tgz", - "integrity": "sha1-WqADz76Uxf+GbE59ZouxxNuts2k=", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.3.1.tgz", + "integrity": "sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==", "dev": true, "requires": { - "json-stable-stringify": "^1.0.0", - "through2-filter": "^2.0.0" + "json-stable-stringify-without-jsonify": "^1.0.1", + "through2-filter": "^3.0.0" } }, "universalify": { @@ -10845,9 +16057,9 @@ "dev": true }, "v8flags": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.1.1.tgz", - "integrity": "sha512-iw/1ViSEaff8NJ3HLyEjawk/8hjJib3E7pvG4pddVXfUg1983s3VGsiClDjhK64MQVDGqc1Q8r18S4VKQZS9EQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.1.3.tgz", + "integrity": "sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w==", "dev": true, "requires": { "homedir-polyfill": "^1.0.1" diff --git a/package.json b/package.json index 91b804947a0..8d5fda2e1ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "3.7.0", + "version": "3.7.1", "description": "The official app for Moodle.", "author": { "name": "Moodle Pty Ltd.", @@ -23,21 +23,21 @@ } ], "scripts": { - "setup": "npm install && cordova prepare && gulp", - "clean": "ionic-app-scripts clean", - "build": "ionic-app-scripts build", - "lint": "ionic-app-scripts lint", + "setup": "npm install && npx cordova prepare && npx gulp", + "clean": "npx ionic-app-scripts clean", + "build": "npx ionic-app-scripts build", + "lint": "npx ionic-app-scripts lint", "ionic:build": "node --max-old-space-size=16384 ./node_modules/@ionic/app-scripts/bin/ionic-app-scripts.js build", - "ionic:serve:before": "gulp", - "ionic:serve": "gulp watch | ionic-app-scripts serve", - "ionic:build:before": "gulp", - "ionic:watch:before": "gulp", - "ionic:build:after": "gulp copy-component-templates", - "preionic:build": "gulp", - "postionic:build": "gulp copy-component-templates", - "desktop.pack": "electron-builder --dir", - "desktop.dist": "electron-builder -p never", - "windows.store": "electron-windows-store --input-directory .\\desktop\\dist\\win-unpacked --output-directory .\\desktop\\store --flatten true -a .\\resources\\desktop -m .\\desktop\\assets\\windows\\AppXManifest.xml --package-version 0.0.0.0 --package-name MoodleDesktop" + "ionic:serve:before": "npx gulp", + "ionic:serve": "npx gulp watch & npx ionic-app-scripts serve -b --devapp --address=0.0.0.0", + "ionic:build:before": "npx gulp", + "ionic:watch:before": "npx gulp", + "ionic:build:after": "npx gulp copy-component-templates", + "preionic:build": "npx gulp", + "postionic:build": "npx gulp copy-component-templates", + "desktop.pack": "npx electron-builder --dir", + "desktop.dist": "npx electron-builder -p never", + "windows.store": "npx electron-windows-store --input-directory .\\desktop\\dist\\win-unpacked --output-directory .\\desktop\\store -a .\\resources\\desktop -m .\\desktop\\assets\\windows\\AppXManifest.xml --package-version 0.0.0.0 --package-name MoodleDesktop" }, "dependencies": { "@angular/animations": "5.2.10", @@ -80,27 +80,28 @@ "@types/promise.prototype.finally": "2.0.2", "chart.js": "2.7.2", "com-darryncampbell-cordova-plugin-intent": "1.1.7", + "cordova": "8.1.2", "cordova-android": "7.1.2", - "cordova-android-support-gradle-release": "3.0.0", - "cordova-clipboard": "1.2.1", + "cordova-android-support-gradle-release": "3.0.1", + "cordova-clipboard": "1.3.0", "cordova-ios": "4.5.5", "cordova-plugin-badge": "0.8.8", - "cordova-plugin-camera": "4.0.3", - "cordova-plugin-customurlscheme": "4.3.0", - "cordova-plugin-device": "2.0.2", - "cordova-plugin-file": "6.0.1", + "cordova-plugin-camera": "4.1.0", + "cordova-plugin-customurlscheme": "4.4.0", + "cordova-plugin-device": "2.0.3", + "cordova-plugin-file": "6.0.2", "cordova-plugin-file-opener2": "2.0.19", "cordova-plugin-file-transfer": "1.7.1", "cordova-plugin-globalization": "1.11.0", - "cordova-plugin-inappbrowser": "3.0.0", + "cordova-plugin-inappbrowser": "3.1.0", "cordova-plugin-ionic-keyboard": "2.1.3", "cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle", - "cordova-plugin-media-capture": "3.0.2", - "cordova-plugin-network-information": "2.0.1", - "cordova-plugin-screen-orientation": "3.0.1", - "cordova-plugin-splashscreen": "5.0.2", - "cordova-plugin-statusbar": "2.4.2", - "cordova-plugin-whitelist": "1.3.3", + "cordova-plugin-media-capture": "3.0.3", + "cordova-plugin-network-information": "2.0.2", + "cordova-plugin-screen-orientation": "3.0.2", + "cordova-plugin-splashscreen": "5.0.3", + "cordova-plugin-statusbar": "2.4.3", + "cordova-plugin-whitelist": "1.3.4", "cordova-plugin-zip": "3.1.0", "cordova-sqlite-storage": "2.6.0", "cordova-support-google-services": "1.2.1", @@ -124,7 +125,7 @@ "@ionic/app-scripts": "3.2.2", "electron-builder-lib": "20.23.1", "electron-rebuild": "1.8.1", - "gulp": "4.0.0", + "gulp": "4.0.2", "gulp-clip-empty-files": "0.1.2", "gulp-concat": "2.6.1", "gulp-flatten": "0.4.0", @@ -210,11 +211,12 @@ } ], "compression": "maximum", - "electronVersion": "4.0.1", + "electronVersion": "4.2.5", "mac": { "category": "public.app-category.education", "icon": "resources/desktop/icon.icns", "target": "mas", + "bundleVersion": "3.7.0", "extendInfo": { "ElectronTeamID": "2NU57U5PAW" } diff --git a/scripts/aot.sh b/scripts/aot.sh index 5fa706e6472..931d8e4b56b 100755 --- a/scripts/aot.sh +++ b/scripts/aot.sh @@ -1,5 +1,8 @@ #!/bin/bash +# List first level of installed libraries so we can check the installed versions. +npm list --depth=0 + # Compile AOT. if [ $TRAVIS_BRANCH == 'integration' ] || [ $TRAVIS_BRANCH == 'master' ] || [ $TRAVIS_BRANCH == 'desktop' ] || [ -z $TRAVIS_BRANCH ] ; then cd scripts @@ -40,17 +43,34 @@ if [ ! -z $GIT_ORG ] && [ ! -z $GIT_TOKEN ] ; then gitfolder=${PWD##*/} git clone --depth 1 --no-single-branch https://github.com/$GIT_ORG/moodlemobile-phonegapbuild.git ../pgb pushd ../pgb + + mkdir /tmp/travistemp + cp .travis.yml /tmp/travistemp + mkdir /tmp/travistemp/scripts + cp scripts/* /tmp/travistemp/scripts + git checkout $TRAVIS_BRANCH - rm -Rf assets build index.html templates - cp -Rf ../$gitfolder/www/* ./ - rm -Rf assets/countries assets/mimetypes + + rm -Rf assets build index.html templates www destkop + + if [ $TRAVIS_BRANCH == 'desktop' ] ; then + cp -Rf ../$gitfolder/desktop ./ + cp -Rf ../$gitfolder/package.json ./ + cp -Rf ../$gitfolder/www ./ + rm -Rf www/assets/countries www/assets/mimetypes + else + cp -Rf ../$gitfolder/www/* ./ + rm -Rf assets/countries assets/mimetypes + fi + + cp /tmp/travistemp/.travis.yml .travis.yml + mkdir scripts + cp /tmp/travistemp/scripts/* scripts + + git add . git commit -m "Travis build: $TRAVIS_BUILD_NUMBER" git push https://$GIT_TOKEN@github.com/$GIT_ORG/moodlemobile-phonegapbuild.git popd fi -if [ ! -z $GIT_ORG_PRIVATE ] && [ ! -z $GIT_TOKEN ] && [ $TRAVIS_BRANCH == 'desktop' ] && [ $TRAVIS_OS_NAME == 'linux' ]; then - ./scripts/linux.sh -fi - diff --git a/scripts/lang_functions.php b/scripts/lang_functions.php new file mode 100644 index 00000000000..af1e2ab3b31 --- /dev/null +++ b/scripts/lang_functions.php @@ -0,0 +1,426 @@ +. + +/** + * Helper functions converting moodle strings to json. + */ + +function detect_languages($languages, $keys) { + echo "\n\n\n"; + + $all_languages = glob(LANGPACKSFOLDER.'/*' , GLOB_ONLYDIR); + function get_lang_from_dir($dir) { + return str_replace('_', '-', explode('/', $dir)[3]); + } + function get_lang_not_wp($langname) { + return (substr($langname, -3) !== '-wp'); + } + $all_languages = array_map('get_lang_from_dir', $all_languages); + $all_languages = array_filter($all_languages, 'get_lang_not_wp'); + + $detect_lang = array_diff($all_languages, $languages); + $new_langs = array(); + foreach ($detect_lang as $lang) { + reset_translations_strings(); + $new = detect_lang($lang, $keys); + if ($new) { + $new_langs[$lang] = $lang; + } + } + + return $new_langs; +} + +function build_languages($languages, $keys, $added_langs = []) { + // Process the languages. + foreach ($languages as $lang) { + reset_translations_strings(); + $ok = build_lang($lang, $keys); + if ($ok) { + $added_langs[$lang] = $lang; + } + } + + return $added_langs; +} + +function get_langindex_keys() { + // Process the index file, just once. + $keys = file_get_contents('langindex.json'); + $keys = (array) json_decode($keys); + + foreach ($keys as $key => $value) { + $map = new StdClass(); + if ($value == 'local_moodlemobileapp') { + $map->file = $value; + $map->string = $key; + } else { + $exp = explode('/', $value, 2); + $map->file = $exp[0]; + if (count($exp) == 2) { + $map->string = $exp[1]; + } else { + $exp = explode('.', $key, 3); + + if (count($exp) == 3) { + $map->string = $exp[2]; + } else { + $map->string = $exp[1]; + } + } + } + + $keys[$key] = $map; + } + + $total = count($keys); + echo "Total strings to translate $total\n"; + + return $keys; +} + +function add_langs_to_config($langs, $config) { + $changed = false; + $config_langs = get_object_vars($config['languages']); + foreach ($langs as $lang) { + if (!isset($config_langs[$lang])) { + $langfoldername = str_replace('-', '_', $lang); + + $string = []; + include(LANGPACKSFOLDER.'/'.$langfoldername.'/langconfig.php'); + $config['languages']->$lang = $string['thislanguage']; + $changed = true; + } + } + + if ($changed) { + // Sort languages by key. + $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)); + } +} + +function get_langfolder($lang) { + $folder = LANGPACKSFOLDER.'/'.str_replace('-', '_', $lang); + if (!is_dir($folder) || !is_file($folder.'/langconfig.php')) { + echo "Cannot translate $folder, folder not found"; + + return false; + } + + return $folder; +} + +function get_translation_strings($langfoldername, $file, $override_folder = false) { + global $strings; + + if (isset($strings[$file])) { + return $strings[$file]; + } + + $string = import_translation_strings($langfoldername, $file); + $string_override = $override_folder ? import_translation_strings($override_folder, $file) : false; + + if ($string) { + $strings[$file] = $string; + if ($string_override) { + $strings[$file] = array_merge($strings[$file], $string_override); + } + } else if ($string_override) { + $strings[$file] = $string_override; + } else { + $strings[$file] = false; + } + + return $strings[$file]; +} + +function import_translation_strings($langfoldername, $file) { + $path = $langfoldername.'/'.$file.'.php'; + // Apply translations. + if (!file_exists($path)) { + return false; + } + + $string = []; + include($path); + + return $string; +} + +function reset_translations_strings() { + global $strings; + $strings = []; +} + +function build_lang($lang, $keys) { + $langfoldername = get_langfolder($lang); + if (!$langfoldername) { + return false; + } + + if (OVERRIDE_LANG_SUFIX) { + $override_langfolder = get_langfolder($lang.OVERRIDE_LANG_SUFIX); + } else { + $override_langfolder = false; + } + + $total = count ($keys); + $local = 0; + + $string = get_translation_strings($langfoldername, 'langconfig'); + $parent = isset($string['parentlanguage']) ? $string['parentlanguage'] : ""; + + echo "Processing $lang"; + if ($parent != "" && $parent != $lang) { + echo " ($parent)"; + } + + $langFile = false; + // Not yet translated. Do not override. + if (file_exists(ASSETSPATH.$lang.'.json')) { + // Load lang files just once. + $langFile = file_get_contents(ASSETSPATH.$lang.'.json'); + $langFile = (array) json_decode($langFile); + } + + $translations = []; + // Add the translation to the array. + foreach ($keys as $key => $value) { + $string = get_translation_strings($langfoldername, $value->file, $override_langfolder); + // Apply translations. + if (!$string) { + if (TOTRANSLATE) { + echo "\n\t\To translate $value->string on $value->file"; + } + continue; + } + + if (!isset($string[$value->string]) || ($lang == 'en' && $value->file == 'local_moodlemobileapp')) { + // Not yet translated. Do not override. + if ($langFile && is_array($langFile) && isset($langFile[$key])) { + $translations[$key] = $langFile[$key]; + $local++; + } + if (TOTRANSLATE) { + echo "\n\t\tTo translate $value->string on $value->file"; + } + continue; + } else { + $text = $string[$value->string]; + } + + if ($value->file != 'local_moodlemobileapp') { + $text = str_replace('$a->', '$a.', $text); + $text = str_replace('{$a', '{{$a', $text); + $text = str_replace('}', '}}', $text); + // Prevent double. + $text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text); + } else { + $local++; + } + + $translations[$key] = html_entity_decode($text); + } + + // Sort and save. + ksort($translations); + file_put_contents(ASSETSPATH.$lang.'.json', str_replace('\/', '/', json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))); + + $success = count($translations); + $percentage = floor($success/$total * 100); + echo "\t\t$success of $total -> $percentage% ($local local)\n"; + + if ($lang == 'en') { + generate_local_moodlemobileapp($keys, $translations); + override_component_lang_files($keys, $translations); + } + + return true; +} + +function detect_lang($lang, $keys) { + $langfoldername = get_langfolder($lang); + if (!$langfoldername) { + return false; + } + + $total = count ($keys); + $success = 0; + $local = 0; + + $string = get_translation_strings($langfoldername, 'langconfig'); + $parent = isset($string['parentlanguage']) ? $string['parentlanguage'] : ""; + if (!isset($string['thislanguage'])) { + echo "Cannot translate $lang, translated name not found"; + return false; + } + + echo "Checking $lang"; + if ($parent != "" && $parent != $lang) { + echo " ($parent)"; + } + $langname = $string['thislanguage']; + echo " ".$langname." -D"; + + // Add the translation to the array. + foreach ($keys as $key => $value) { + $string = get_translation_strings($langfoldername, $value->file); + // Apply translations. + if (!$string) { + continue; + } + + if (!isset($string[$value->string])) { + continue; + } else { + $text = $string[$value->string]; + } + + if ($value->file == 'local_moodlemobileapp') { + $local++; + } + + $success++; + } + + $percentage = floor($success/$total * 100); + echo "\t\t$success of $total -> $percentage% ($local local)"; + if (($percentage > 75 && $local > 50) || ($percentage > 50 && $local > 75)) { + echo " \t DETECTED\n"; + return true; + } + echo "\n"; + + return false; +} + +function save_key($key, $value, $path) { + $filePath = $path . '/en.json'; + + $file = file_get_contents($filePath); + $file = (array) json_decode($file); + $value = html_entity_decode($value); + 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))); + } +} + +function override_component_lang_files($keys, $translations) { + echo "Override component lang files.\n"; + foreach ($translations as $key => $value) { + $path = '../src/'; + $exp = explode('.', $key, 3); + + $type = $exp[0]; + if (count($exp) == 3) { + $component = $exp[1]; + $plainid = $exp[2]; + } else { + $component = 'moodle'; + $plainid = $exp[1]; + } + switch($type) { + case 'core': + case 'addon': + switch($component) { + case 'moodle': + $path .= 'lang'; + break; + default: + $path .= $type.'/'.str_replace('_', '/', $component).'/lang'; + break; + } + break; + case 'assets': + $path .= $type.'/'.$component; + break; + + } + + if (is_file($path.'/en.json')) { + save_key($plainid, $value, $path); + } + } +} + +/** + * Generates local moodle mobile app file to update languages in AMOS. + * + * @param [array] $keys Translation keys. + * @param [array] $translations English translations. + */ +function generate_local_moodlemobileapp($keys, $translations) { + echo "Generate local_moodlemobileapp.\n"; + $string = '. + +/** + * Version details. + * + * @package local + * @subpackage moodlemobileapp + * @copyright 2014 Juan Leyva + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string[\'appstoredescription\'] = \'NOTE: This official Moodle Mobile app will ONLY work with Moodle sites that have been set up to allow it. Please talk to your Moodle administrator if you have any problems connecting. + +If your Moodle site has been configured correctly, you can use this app to: + +- browse the content of your courses, even when offline +- receive instant notifications of messages and other events +- quickly find and contact other people in your courses +- upload images, audio, videos and other files from your mobile device +- view your course grades +- and more! + +Please see http://docs.moodle.org/en/Mobile_app for all the latest information. + +We’d really appreciate any good reviews about the functionality so far, and your suggestions on what else you want this app to do! + +The app requires the following permissions: +Record audio - For recording audio to upload to Moodle +Read and modify the contents of your SD card - Contents are downloaded to the SD Card so you can see them offline +Network access - To be able to connect with your Moodle site and check if you are connected or not to switch to offline mode +Run at startup - So you receive local notifications even when the app is running in the background +Prevent phone from sleeping - So you can receive push notifications anytime\';'."\n"; + foreach ($keys as $key => $value) { + if (isset($translations[$key]) && $value->file == 'local_moodlemobileapp') { + $string .= '$string[\''.$key.'\'] = \''.str_replace("'", "\'", $translations[$key]).'\';'."\n"; + } + } + $string .= '$string[\'pluginname\'] = \'Moodle Mobile language strings\';'."\n"; + + file_put_contents('../../moodle-local_moodlemobileapp/lang/en/local_moodlemobileapp.php', $string."\n"); +} diff --git a/scripts/langindex.json b/scripts/langindex.json index 7381c6fc8b7..fe54142bdcf 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -27,6 +27,17 @@ "addon.badges.version": "badges", "addon.badges.warnexpired": "badges", "addon.block_activitymodules.pluginname": "block_activity_modules", + "addon.block_badges.pluginname": "block_badges", + "addon.block_blogmenu.pluginname": "block_blog_menu", + "addon.block_blogrecent.nocourses": "block_blog_recent", + "addon.block_blogrecent.pluginname": "block_blog_recent", + "addon.block_blogtags.pluginname": "block_blog_tags", + "addon.block_calendarmonth.pluginname": "block_calendar_month", + "addon.block_calendarupcoming.pluginname": "block_calendar_upcoming", + "addon.block_comments.pluginname": "block_comments", + "addon.block_completionstatus.pluginname": "block_completionstatus", + "addon.block_glossaryrandom.pluginname": "block_glossary_random", + "addon.block_learningplans.pluginname": "block_lp", "addon.block_myoverview.all": "block_myoverview", "addon.block_myoverview.favourites": "block_myoverview", "addon.block_myoverview.future": "block_myoverview", @@ -38,13 +49,20 @@ "addon.block_myoverview.past": "block_myoverview", "addon.block_myoverview.pluginname": "block_myoverview", "addon.block_myoverview.title": "block_myoverview", + "addon.block_newsitems.pluginname": "block_news_items", + "addon.block_onlineusers.pluginname": "block_online_users", + "addon.block_privatefiles.pluginname": "block_private_files", + "addon.block_recentactivity.pluginname": "block_recent_activity", "addon.block_recentlyaccessedcourses.nocourses": "block_recentlyaccessedcourses", "addon.block_recentlyaccessedcourses.pluginname": "block_recentlyaccessedcourses", "addon.block_recentlyaccesseditems.noitems": "block_recentlyaccesseditems", "addon.block_recentlyaccesseditems.pluginname": "block_recentlyaccesseditems", + "addon.block_rssclient.pluginname": "block_rss_client", + "addon.block_selfcompletion.pluginname": "block_selfcompletion", "addon.block_sitemainmenu.pluginname": "block_site_main_menu", "addon.block_starredcourses.nocourses": "block_starredcourses", "addon.block_starredcourses.pluginname": "block_starredcourses", + "addon.block_tags.pluginname": "block_tags", "addon.block_timeline.duedate": "block_timeline", "addon.block_timeline.next30days": "block_timeline", "addon.block_timeline.next3months": "block_timeline", @@ -66,18 +84,61 @@ "addon.blog.publishtoworld": "blog", "addon.blog.showonlyyourentries": "local_moodlemobileapp", "addon.blog.siteblogheading": "blog", + "addon.calendar.allday": "calendar", "addon.calendar.calendar": "calendar", + "addon.calendar.calendarevent": "local_moodlemobileapp", "addon.calendar.calendarevents": "local_moodlemobileapp", "addon.calendar.calendarreminders": "local_moodlemobileapp", + "addon.calendar.confirmeventdelete": "calendar", + "addon.calendar.confirmeventseriesdelete": "calendar", + "addon.calendar.currentmonth": "local_moodlemobileapp", + "addon.calendar.daynext": "calendar", + "addon.calendar.dayprev": "calendar", "addon.calendar.defaultnotificationtime": "local_moodlemobileapp", + "addon.calendar.deleteallevents": "calendar", + "addon.calendar.deleteevent": "calendar", + "addon.calendar.deleteoneevent": "calendar", + "addon.calendar.durationminutes": "calendar", + "addon.calendar.durationnone": "calendar", + "addon.calendar.durationuntil": "calendar", + "addon.calendar.editevent": "calendar", "addon.calendar.errorloadevent": "local_moodlemobileapp", "addon.calendar.errorloadevents": "local_moodlemobileapp", + "addon.calendar.eventcalendareventdeleted": "calendar", + "addon.calendar.eventduration": "calendar", "addon.calendar.eventendtime": "calendar", + "addon.calendar.eventkind": "calendar", + "addon.calendar.eventname": "calendar", "addon.calendar.eventstarttime": "calendar", + "addon.calendar.eventtype": "calendar", + "addon.calendar.fri": "calendar", + "addon.calendar.friday": "calendar", "addon.calendar.gotoactivity": "calendar", + "addon.calendar.invalidtimedurationminutes": "calendar", + "addon.calendar.invalidtimedurationuntil": "calendar", + "addon.calendar.mon": "calendar", + "addon.calendar.monday": "calendar", + "addon.calendar.monthlyview": "calendar", + "addon.calendar.newevent": "calendar", "addon.calendar.noevents": "local_moodlemobileapp", + "addon.calendar.nopermissiontoupdatecalendar": "error", "addon.calendar.reminders": "local_moodlemobileapp", + "addon.calendar.repeatedevents": "calendar", + "addon.calendar.repeateditall": "calendar", + "addon.calendar.repeateditthis": "calendar", + "addon.calendar.repeatevent": "calendar", + "addon.calendar.repeatweeksl": "calendar", + "addon.calendar.sat": "calendar", + "addon.calendar.saturday": "calendar", "addon.calendar.setnewreminder": "local_moodlemobileapp", + "addon.calendar.sun": "calendar", + "addon.calendar.sunday": "calendar", + "addon.calendar.thu": "calendar", + "addon.calendar.thursday": "calendar", + "addon.calendar.today": "calendar", + "addon.calendar.tomorrow": "calendar", + "addon.calendar.tue": "calendar", + "addon.calendar.tuesday": "calendar", "addon.calendar.typecategory": "calendar", "addon.calendar.typeclose": "calendar", "addon.calendar.typecourse": "calendar", @@ -87,6 +148,11 @@ "addon.calendar.typeopen": "calendar", "addon.calendar.typesite": "calendar", "addon.calendar.typeuser": "calendar", + "addon.calendar.upcomingevents": "calendar", + "addon.calendar.wed": "calendar", + "addon.calendar.wednesday": "calendar", + "addon.calendar.when": "calendar", + "addon.calendar.yesterday": "calendar", "addon.competency.activities": "tool_lp", "addon.competency.competencies": "competency", "addon.competency.competenciesmostoftennotproficientincourse": "tool_lp", @@ -355,6 +421,7 @@ "addon.mod_assign_submission_onlinetext.wordlimitexceeded": "assignsubmission_onlinetext", "addon.mod_book.errorchapter": "book", "addon.mod_book.modulenameplural": "book", + "addon.mod_book.tagarea_book_chapters": "book", "addon.mod_book.toc": "book", "addon.mod_chat.beep": "chat", "addon.mod_chat.chatreport": "chat", @@ -414,6 +481,7 @@ "addon.mod_data.confirmdeleterecord": "data", "addon.mod_data.descending": "data", "addon.mod_data.disapprove": "data", + "addon.mod_data.edittagsnotsupported": "local_moodlemobileapp", "addon.mod_data.emptyaddform": "data", "addon.mod_data.entrieslefttoadd": "data", "addon.mod_data.entrieslefttoaddtoview": "data", @@ -438,8 +506,10 @@ "addon.mod_data.recorddisapproved": "data", "addon.mod_data.resetsettings": "data", "addon.mod_data.search": "data", + "addon.mod_data.searchbytagsnotsupported": "local_moodlemobileapp", "addon.mod_data.selectedrequired": "data", "addon.mod_data.single": "data", + "addon.mod_data.tagarea_data_records": "data", "addon.mod_data.timeadded": "data", "addon.mod_data.timemodified": "data", "addon.mod_data.usedate": "data", @@ -532,6 +602,7 @@ "addon.mod_forum.reply": "forum", "addon.mod_forum.replyplaceholder": "forum", "addon.mod_forum.subject": "forum", + "addon.mod_forum.tagarea_forum_posts": "forum", "addon.mod_forum.thisforumhasduedate": "forum", "addon.mod_forum.thisforumisdue": "forum", "addon.mod_forum.unlockdiscussion": "forum", @@ -566,9 +637,11 @@ "addon.mod_glossary.modulenameplural": "glossary", "addon.mod_glossary.noentriesfound": "local_moodlemobileapp", "addon.mod_glossary.searchquery": "local_moodlemobileapp", + "addon.mod_glossary.tagarea_glossary_entries": "glossary", "addon.mod_imscp.deploymenterror": "imscp", "addon.mod_imscp.modulenameplural": "imscp", "addon.mod_imscp.showmoduledescription": "local_moodlemobileapp", + "addon.mod_imscp.toc": "imscp", "addon.mod_lesson.answer": "lesson", "addon.mod_lesson.attempt": "lesson", "addon.mod_lesson.attemptheader": "lesson", @@ -665,6 +738,7 @@ "addon.mod_quiz.attemptnumber": "quiz", "addon.mod_quiz.attemptquiznow": "quiz", "addon.mod_quiz.attemptstate": "quiz", + "addon.mod_quiz.canattemptbutnotsubmit": "local_moodlemobileapp", "addon.mod_quiz.cannotsubmitquizdueto": "local_moodlemobileapp", "addon.mod_quiz.clearchoice": "qtype_multichoice", "addon.mod_quiz.comment": "quiz", @@ -737,6 +811,7 @@ "addon.mod_quiz.warningattemptfinished": "local_moodlemobileapp", "addon.mod_quiz.warningdatadiscarded": "local_moodlemobileapp", "addon.mod_quiz.warningdatadiscardedfromfinished": "local_moodlemobileapp", + "addon.mod_quiz.warningquestionsnotsupported": "local_moodlemobileapp", "addon.mod_quiz.yourfinalgradeis": "quiz", "addon.mod_resource.errorwhileloadingthecontent": "local_moodlemobileapp", "addon.mod_resource.modifieddate": "resource", @@ -820,6 +895,7 @@ "addon.mod_wiki.pageexists": "wiki", "addon.mod_wiki.pagename": "wiki", "addon.mod_wiki.subwiki": "local_moodlemobileapp", + "addon.mod_wiki.tagarea_wiki_pages": "wiki", "addon.mod_wiki.titleshouldnotbeempty": "local_moodlemobileapp", "addon.mod_wiki.viewpage": "local_moodlemobileapp", "addon.mod_wiki.wikipage": "local_moodlemobileapp", @@ -898,7 +974,9 @@ "addon.mod_workshop_assessment_rubric.mustchooseone": "workshopform_rubric", "addon.notes.addnewnote": "notes", "addon.notes.coursenotes": "notes", + "addon.notes.deleteconfirm": "notes", "addon.notes.eventnotecreated": "notes", + "addon.notes.eventnotedeleted": "notes", "addon.notes.nonotes": "notes", "addon.notes.note": "notes", "addon.notes.notes": "notes", @@ -1231,6 +1309,7 @@ "core.answered": "quiz", "core.areyousure": "moodle", "core.back": "moodle", + "core.block.blocks": "moodle", "core.cancel": "moodle", "core.cannotconnect": "local_moodlemobileapp", "core.cannotdownloadfiles": "local_moodlemobileapp", @@ -1246,6 +1325,16 @@ "core.clicktoseefull": "local_moodlemobileapp", "core.close": "repository", "core.comments": "moodle", + "core.comments.addcomment": "moodle", + "core.comments.comments": "moodle", + "core.comments.commentscount": "moodle", + "core.comments.commentsnotworking": "local_moodlemobileapp", + "core.comments.deletecommentbyon": "moodle", + "core.comments.eventcommentcreated": "moodle", + "core.comments.eventcommentdeleted": "moodle", + "core.comments.nocomments": "moodle", + "core.comments.savecomment": "moodle", + "core.comments.warningcommentsnotsent": "local_moodlemobileapp", "core.commentscount": "moodle", "core.commentsnotworking": "local_moodlemobileapp", "core.completion-alt-auto-fail": "completion", @@ -1260,6 +1349,8 @@ "core.completion-alt-manual-y-override": "completion", "core.confirmcanceledit": "local_moodlemobileapp", "core.confirmdeletefile": "repository", + "core.confirmgotabroot": "local_moodlemobileapp", + "core.confirmgotabrootdefault": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", @@ -1306,6 +1397,7 @@ "core.course.warningmanualcompletionmodified": "local_moodlemobileapp", "core.course.warningofflinemanualcompletiondeleted": "local_moodlemobileapp", "core.coursedetails": "moodle", + "core.coursenogroups": "local_moodlemobileapp", "core.courses.addtofavourites": "block_myoverview", "core.courses.allowguests": "enrol_guest", "core.courses.availablecourses": "moodle", @@ -1323,6 +1415,7 @@ "core.courses.filtermycourses": "local_moodlemobileapp", "core.courses.frontpage": "admin", "core.courses.hidecourse": "block_myoverview", + "core.courses.ignore": "admin", "core.courses.mycourses": "moodle", "core.courses.mymoodle": "admin", "core.courses.nocourses": "my", @@ -1333,6 +1426,7 @@ "core.courses.password": "local_moodlemobileapp", "core.courses.paymentrequired": "moodle", "core.courses.paypalaccepted": "enrol_paypal", + "core.courses.reload": "moodle", "core.courses.removefromfavourites": "block_myoverview", "core.courses.search": "moodle", "core.courses.searchcourses": "moodle", @@ -1383,6 +1477,7 @@ "core.erroropenfilenoextension": "local_moodlemobileapp", "core.erroropenpopup": "local_moodlemobileapp", "core.errorrenamefile": "local_moodlemobileapp", + "core.errorsomedatanotdownloaded": "local_moodlemobileapp", "core.errorsync": "local_moodlemobileapp", "core.errorsyncblocked": "local_moodlemobileapp", "core.explanationdigitalminor": "moodle", @@ -1435,6 +1530,7 @@ "core.grades.range": "grades", "core.grades.rank": "grades", "core.grades.weight": "grades", + "core.group": "moodle", "core.groupsseparate": "moodle", "core.groupsvisible": "moodle", "core.hasdatatosync": "local_moodlemobileapp", @@ -1446,6 +1542,7 @@ "core.image": "local_moodlemobileapp", "core.imageviewer": "local_moodlemobileapp", "core.info": "moodle", + "core.invalidformdata": "error", "core.ios": "local_moodlemobileapp", "core.labelsep": "langconfig", "core.lastaccess": "moodle", @@ -1465,6 +1562,8 @@ "core.login.confirmdeletesite": "local_moodlemobileapp", "core.login.connect": "local_moodlemobileapp", "core.login.connecttomoodle": "local_moodlemobileapp", + "core.login.connecttomoodleapp": "local_moodlemobileapp", + "core.login.connecttoworkplaceapp": "local_moodlemobileapp", "core.login.contactyouradministrator": "local_moodlemobileapp", "core.login.contactyouradministratorissue": "local_moodlemobileapp", "core.login.createaccount": "moodle", @@ -1602,12 +1701,14 @@ "core.nopermissionerror": "local_moodlemobileapp", "core.nopermissions": "error", "core.noresults": "moodle", + "core.noselection": "form", "core.notapplicable": "local_moodlemobileapp", "core.notenrolledprofile": "moodle", "core.notice": "moodle", "core.notingroup": "moodle", "core.notsent": "local_moodlemobileapp", "core.now": "moodle", + "core.nummore": "local_moodlemobileapp", "core.numwords": "moodle", "core.offline": "message", "core.ok": "moodle", @@ -1670,6 +1771,9 @@ "core.sec": "moodle", "core.secs": "moodle", "core.seemoredetail": "survey", + "core.selectacategory": "moodle", + "core.selectacourse": "moodle", + "core.selectagroup": "moodle", "core.send": "message", "core.sending": "chat", "core.serverconnection": "error", @@ -1695,6 +1799,8 @@ "core.settings.disabled": "lesson", "core.settings.displayformat": "local_moodlemobileapp", "core.settings.enabledownloadsection": "local_moodlemobileapp", + "core.settings.enablefirebaseanalytics": "local_moodlemobileapp", + "core.settings.enablefirebaseanalyticsdescription": "local_moodlemobileapp", "core.settings.enablerichtexteditor": "local_moodlemobileapp", "core.settings.enablerichtexteditordescription": "local_moodlemobileapp", "core.settings.enablesyncwifi": "local_moodlemobileapp", @@ -1703,6 +1809,8 @@ "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.general": "moodle", "core.settings.language": "moodle", "core.settings.license": "moodle", @@ -1738,6 +1846,7 @@ "core.sharedfiles.sharedfiles": "local_moodlemobileapp", "core.sharedfiles.successstorefile": "local_moodlemobileapp", "core.show": "moodle", + "core.showless": "form", "core.showmore": "form", "core.site": "moodle", "core.sitehome.sitehome": "moodle", @@ -1770,6 +1879,20 @@ "core.submit": "moodle", "core.success": "moodle", "core.tablet": "local_moodlemobileapp", + "core.tag.defautltagcoll": "moodle", + "core.tag.errorareanotsupported": "local_moodlemobileapp", + "core.tag.inalltagcoll": "moodle", + "core.tag.itemstaggedwith": "moodle", + "core.tag.notagsfound": "moodle", + "core.tag.searchtags": "moodle", + "core.tag.showingfirsttags": "moodle", + "core.tag.tag": "moodle", + "core.tag.tagarea_course": "moodle", + "core.tag.tagarea_course_modules": "moodle", + "core.tag.tagarea_post": "moodle", + "core.tag.tagarea_user": "moodle", + "core.tag.tags": "moodle", + "core.tag.warningareasnotsupported": "local_moodlemobileapp", "core.teachers": "moodle", "core.thereisdatatosync": "local_moodlemobileapp", "core.thisdirection": "langconfig", @@ -1786,6 +1909,7 @@ "core.unlimited": "moodle", "core.unzipping": "local_moodlemobileapp", "core.upgraderunning": "error", + "core.user": "moodle", "core.user.address": "moodle", "core.user.city": "moodle", "core.user.contact": "local_moodlemobileapp", diff --git a/scripts/linux.sh b/scripts/linux.sh deleted file mode 100755 index 571102f5b59..00000000000 --- a/scripts/linux.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# -# Script for generating the Desktop builds -# - -sudo apt-get install -y libnss3-dev - -npm install -g electron-builder electron - -electron-builder install-app-deps - -jq -s '.[0] + {"name": "moodledesktop"}' package.json > package_new.json -mv package_new.json package.json - -rm -Rf desktop/dist - -npm run desktop.dist -- -l --x64 --ia32 - -if [ ! -z $GIT_ORG_PRIVATE ] && [ ! -z $GIT_TOKEN ] ; then - git clone -q https://$GIT_TOKEN@github.com/moodlemobile/bma-apps-builds.git ../apps - - mv desktop/dist/*.AppImage ../apps - - cd ../apps - - chmod +x *.AppImage - mv *i386.AppImage linux-ia32.AppImage - mv Moodle*.AppImage linux-x64.AppImage - ls - - tar -czvf MoodleDesktop32.tar.gz linux-ia32.AppImage - tar -czvf MoodleDesktop64.tar.gz linux-x64.AppImage - rm *.AppImage - - git add . - git commit -m "Linux desktop versions from Travis build $TRAVIS_BUILD_NUMBER" - git push -fi diff --git a/scripts/moodle_to_json.php b/scripts/moodle_to_json.php index bc6d128e544..e82eb95236b 100644 --- a/scripts/moodle_to_json.php +++ b/scripts/moodle_to_json.php @@ -26,6 +26,10 @@ define('LANGPACKSFOLDER', '../../moodle-langpacks'); define('ASSETSPATH', '../src/assets/lang/'); define('CONFIG', '../src/config.json'); +define('OVERRIDE_LANG_SUFIX', false); + +global $strings; +require_once('lang_functions.php'); $config = file_get_contents(CONFIG); $config = (array) json_decode($config); @@ -42,355 +46,17 @@ $languages = $config_langs; } -// Process the index file, just once. -$keys = file_get_contents('langindex.json'); -$keys = (array) json_decode($keys); - -foreach ($keys as $key => $value) { - $map = new StdClass(); - if ($value == 'local_moodlemobileapp') { - $map->file = $value; - $map->string = $key; - } else { - $exp = explode('/', $value, 2); - $map->file = $exp[0]; - if (count($exp) == 2) { - $map->string = $exp[1]; - } else { - $exp = explode('.', $key, 3); - - if (count($exp) == 3) { - $map->string = $exp[2]; - } else { - $map->string = $exp[1]; - } - } - } - - $keys[$key] = $map; -} -$total = count ($keys); - -echo "Total strings to translate $total\n"; +$keys = get_langindex_keys(); -$add_langs = array(); -// Process the languages. -foreach ($languages as $lang) { - $ok = build_lang($lang, $keys, $total); - if ($ok) { - $add_langs[$lang] = $lang; - } -} +$added_langs = build_languages($languages, $keys); if ($forcedetect) { - echo "\n\n\n"; - - $all_languages = glob(LANGPACKSFOLDER.'/*' , GLOB_ONLYDIR); - function get_lang_from_dir($dir) { - return str_replace('_', '-', explode('/', $dir)[3]); - } - $all_languages = array_map('get_lang_from_dir', $all_languages); - $detect_lang = array_diff($all_languages, $languages); - $new_langs = array(); - foreach ($detect_lang as $lang) { - $new = detect_lang($lang, $keys, $total); - if ($new) { - $new_langs[$lang] = $lang; - } - } + $new_langs = detect_languages($languages, $keys); if (!empty($new_langs)) { echo "\n\n\nThe following languages are going to be added\n\n\n"; - foreach ($new_langs as $lang) { - $ok = build_lang($lang, $keys, $total); - if ($ok) { - $add_langs[$lang] = $lang; - } - } - add_langs_to_config($add_langs, $config); - } -} else { - add_langs_to_config($add_langs, $config); -} - -function add_langs_to_config($langs, $config) { - $changed = false; - $config_langs = get_object_vars($config['languages']); - foreach ($langs as $lang) { - if (!isset($config_langs[$lang])) { - $langfoldername = str_replace('-', '_', $lang); - - $string = []; - include(LANGPACKSFOLDER.'/'.$langfoldername.'/langconfig.php'); - $config['languages']->$lang = $string['thislanguage']; - $changed = true; - } - } - - if ($changed) { - // Sort languages by key. - $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)); - } -} - -function build_lang($lang, $keys, $total) { - $local = 0; - $langFile = false; - $translations = []; - $langfoldername = str_replace('-', '_', $lang); - - if (!is_dir(LANGPACKSFOLDER.'/'.$langfoldername) || !is_file(LANGPACKSFOLDER.'/'.$langfoldername.'/langconfig.php')) { - echo "Cannot translate $langfoldername, folder not found"; - return false; - } - - $string = []; - include(LANGPACKSFOLDER.'/'.$langfoldername.'/langconfig.php'); - $parent = isset($string['parentlanguage']) ? $string['parentlanguage'] : ""; - - echo "Processing $lang"; - if ($parent != "" && $parent != $lang) { - echo "($parent)"; - } - - - // Add the translation to the array. - foreach ($keys as $key => $value) { - $file = LANGPACKSFOLDER.'/'.$langfoldername.'/'.$value->file.'.php'; - // Apply translations. - if (!file_exists($file)) { - if (TOTRANSLATE) { - echo "\n\t\To translate $value->string on $value->file"; - } - continue; - } - - $string = []; - include($file); - - if (!isset($string[$value->string]) || ($lang == 'en' && $value->file == 'local_moodlemobileapp')) { - // Not yet translated. Do not override. - if (!$langFile) { - // Load lang files just once. - $langFile = file_get_contents(ASSETSPATH.$lang.'.json'); - $langFile = (array) json_decode($langFile); - } - if (is_array($langFile) && isset($langFile[$key])) { - $translations[$key] = $langFile[$key]; - $local++; - } - if (TOTRANSLATE) { - echo "\n\t\tTo translate $value->string on $value->file"; - } - continue; - } else { - $text = $string[$value->string]; - } - - if ($value->file != 'local_moodlemobileapp') { - $text = str_replace('$a->', '$a.', $text); - $text = str_replace('{$a', '{{$a', $text); - $text = str_replace('}', '}}', $text); - // Prevent double. - $text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text); - } else { - $local++; - } - - $translations[$key] = html_entity_decode($text); - } - - // Sort and save. - ksort($translations); - file_put_contents(ASSETSPATH.$lang.'.json', str_replace('\/', '/', json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))); - - $success = count($translations); - $percentage = floor($success/$total *100); - echo "\t\t$success of $total -> $percentage% ($local local)\n"; - - if ($lang == 'en') { - generate_local_moodlemobileapp($keys, $translations); - override_component_lang_files($keys, $translations); - } - - return true; -} - -function detect_lang($lang, $keys, $total) { - $success = 0; - $local = 0; - $langfoldername = str_replace('-', '_', $lang); - - if (!is_dir(LANGPACKSFOLDER.'/'.$langfoldername) || !is_file(LANGPACKSFOLDER.'/'.$langfoldername.'/langconfig.php')) { - echo "Cannot translate $langfoldername, folder not found"; - return false; - } - - $string = []; - include(LANGPACKSFOLDER.'/'.$langfoldername.'/langconfig.php'); - $parent = isset($string['parentlanguage']) ? $string['parentlanguage'] : ""; - if (!isset($string['thislanguage'])) { - echo "Cannot translate $langfoldername, name not found"; - return false; - } - - echo "Checking $lang"; - if ($parent != "" && $parent != $lang) { - echo "($parent)"; - } - $langname = $string['thislanguage']; - echo " ".$langname." -D"; - - // Add the translation to the array. - foreach ($keys as $key => $value) { - $file = LANGPACKSFOLDER.'/'.$langfoldername.'/'.$value->file.'.php'; - // Apply translations. - if (!file_exists($file)) { - continue; - } - - $string = []; - include($file); - - if (!isset($string[$value->string])) { - continue; - } else { - $text = $string[$value->string]; - } - - if ($value->file == 'local_moodlemobileapp') { - $local++; - } - - $success++; - } - - $percentage = floor($success/$total *100); - echo "\t\t$success of $total -> $percentage% ($local local)"; - if (($percentage > 75 && $local > 50) || ($percentage > 50 && $local > 75)) { - echo " \t DETECTED\n"; - return true; - } - echo "\n"; - - return false; -} - -function save_key($key, $value, $path) { - $filePath = $path . '/en.json'; - - $file = file_get_contents($filePath); - $file = (array) json_decode($file); - $value = html_entity_decode($value); - if ($file[$key] != $value) { - $file[$key] = $value; - ksort($file); - file_put_contents($filePath, str_replace('\/', '/', json_encode($file, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))); + $added_langs = build_languages($new_langs, $keys, $added_langs); } } -function override_component_lang_files($keys, $translations) { - echo "Override component lang files.\n"; - foreach ($translations as $key => $value) { - $path = '../src/'; - $exp = explode('.', $key, 3); - - $type = $exp[0]; - if (count($exp) == 3) { - $component = $exp[1]; - $plainid = $exp[2]; - } else { - $component = 'moodle'; - $plainid = $exp[1]; - } - switch($type) { - case 'core': - case 'addon': - switch($component) { - case 'moodle': - $path .= 'lang'; - break; - default: - $path .= $type.'/'.str_replace('_', '/', $component).'/lang'; - break; - } - break; - case 'assets': - $path .= $type.'/'.$component; - break; - - } - - if (is_file($path.'/en.json')) { - save_key($plainid, $value, $path); - } - } -} - -/** - * Generates local moodle mobile app file to update languages in AMOS. - * - * @param [array] $keys Translation keys. - * @param [array] $translations English translations. - */ -function generate_local_moodlemobileapp($keys, $translations) { - echo "Generate local_moodlemobileapp.\n"; - $string = '. - -/** - * Version details. - * - * @package local - * @subpackage moodlemobileapp - * @copyright 2014 Juan Leyva - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -$string[\'appstoredescription\'] = \'NOTE: This official Moodle Mobile app will ONLY work with Moodle sites that have been set up to allow it. Please talk to your Moodle administrator if you have any problems connecting. - -If your Moodle site has been configured correctly, you can use this app to: - -- browse the content of your courses, even when offline -- receive instant notifications of messages and other events -- quickly find and contact other people in your courses -- upload images, audio, videos and other files from your mobile device -- view your course grades -- and more! - -Please see http://docs.moodle.org/en/Mobile_app for all the latest information. - -We’d really appreciate any good reviews about the functionality so far, and your suggestions on what else you want this app to do! - -The app requires the following permissions: -Record audio - For recording audio to upload to Moodle -Read and modify the contents of your SD card - Contents are downloaded to the SD Card so you can see them offline -Network access - To be able to connect with your Moodle site and check if you are connected or not to switch to offline mode -Run at startup - So you receive local notifications even when the app is running in the background -Prevent phone from sleeping - So you can receive push notifications anytime\';'."\n"; - foreach ($keys as $key => $value) { - if (isset($translations[$key]) && $value->file == 'local_moodlemobileapp') { - $string .= '$string[\''.$key.'\'] = \''.str_replace("'", "\'", $translations[$key]).'\';'."\n"; - } - } - $string .= '$string[\'pluginname\'] = \'Moodle Mobile language strings\';'."\n"; - - file_put_contents('../../moodle-local_moodlemobileapp/lang/en/local_moodlemobileapp.php', $string."\n"); -} - +add_langs_to_config($added_langs, $config); diff --git a/src/addon/badges/providers/badge-link-handler.ts b/src/addon/badges/providers/badge-link-handler.ts index bcaf759e0c1..144acad5cd1 100644 --- a/src/addon/badges/providers/badge-link-handler.ts +++ b/src/addon/badges/providers/badge-link-handler.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { AddonBadgesProvider } from './badges'; /** @@ -26,7 +26,7 @@ export class AddonBadgesBadgeLinkHandler extends CoreContentLinksHandlerBase { name = 'AddonBadgesBadgeLinkHandler'; pattern = /\/badges\/badge\.php.*([\?\&]hash=)/; - constructor(private badgesProvider: AddonBadgesProvider, private loginHelper: CoreLoginHelperProvider) { + constructor(private badgesProvider: AddonBadgesProvider, private linkHelper: CoreContentLinksHelperProvider) { super(); } @@ -44,8 +44,7 @@ export class AddonBadgesBadgeLinkHandler extends CoreContentLinksHandlerBase { return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect('AddonBadgesIssuedBadgePage', {courseId: 0, badgeHash: params.hash}, siteId); + this.linkHelper.goInSite(navCtrl, 'AddonBadgesIssuedBadgePage', {courseId: 0, badgeHash: params.hash}, siteId); } }]; } diff --git a/src/addon/badges/providers/mybadges-link-handler.ts b/src/addon/badges/providers/mybadges-link-handler.ts index 8a36cbbc0df..190e575acbb 100644 --- a/src/addon/badges/providers/mybadges-link-handler.ts +++ b/src/addon/badges/providers/mybadges-link-handler.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { AddonBadgesProvider } from './badges'; /** @@ -27,7 +27,7 @@ export class AddonBadgesMyBadgesLinkHandler extends CoreContentLinksHandlerBase featureName = 'CoreUserDelegate_AddonBadges'; pattern = /\/badges\/mybadges\.php/; - constructor(private badgesProvider: AddonBadgesProvider, private loginHelper: CoreLoginHelperProvider) { + constructor(private badgesProvider: AddonBadgesProvider, private linkHelper: CoreContentLinksHelperProvider) { super(); } @@ -45,8 +45,7 @@ export class AddonBadgesMyBadgesLinkHandler extends CoreContentLinksHandlerBase return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect('AddonBadgesUserBadgesPage', {}, siteId); + this.linkHelper.goInSite(navCtrl, 'AddonBadgesUserBadgesPage', {}, siteId); } }]; } diff --git a/src/addon/block/activitymodules/providers/block-handler.ts b/src/addon/block/activitymodules/providers/block-handler.ts index 0024324c991..752d23a47aa 100644 --- a/src/addon/block/activitymodules/providers/block-handler.ts +++ b/src/addon/block/activitymodules/providers/block-handler.ts @@ -38,7 +38,7 @@ export class AddonBlockActivityModulesHandler extends CoreBlockBaseHandler { * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/block/badges/badges.module.ts b/src/addon/block/badges/badges.module.ts new file mode 100644 index 00000000000..ff9c450d293 --- /dev/null +++ b/src/addon/block/badges/badges.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockBadgesHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockBadgesHandler + ] +}) +export class AddonBlockBadgesModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockBadgesHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/badges/badges.scss b/src/addon/block/badges/badges.scss new file mode 100644 index 00000000000..3f9d26d8bac --- /dev/null +++ b/src/addon/block/badges/badges.scss @@ -0,0 +1,23 @@ +.addon-block-badges core-block-pre-rendered { + .core-block-content { + ul.badges { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li { + position: relative; + display: inline-block; + padding-top: 1em; + text-align: center; + vertical-align: top; + width: 150px; + + .badge-name { + display: block; + padding: 5px; + } + } + } + } +} \ No newline at end of file diff --git a/src/addon/block/badges/lang/en.json b/src/addon/block/badges/lang/en.json new file mode 100644 index 00000000000..dd957321f57 --- /dev/null +++ b/src/addon/block/badges/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Latest badges" +} \ No newline at end of file diff --git a/src/addon/block/badges/providers/block-handler.ts b/src/addon/block/badges/providers/block-handler.ts new file mode 100644 index 00000000000..d6cfb677a39 --- /dev/null +++ b/src/addon/block/badges/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockBadgesHandler extends CoreBlockBaseHandler { + name = 'AddonBlockBadges'; + blockName = 'badges'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_badges.pluginname', + class: 'addon-block-badges', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/blogmenu/blogmenu.module.ts b/src/addon/block/blogmenu/blogmenu.module.ts new file mode 100644 index 00000000000..cbca102001d --- /dev/null +++ b/src/addon/block/blogmenu/blogmenu.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockBlogMenuHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockBlogMenuHandler + ] +}) +export class AddonBlockBlogMenuModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockBlogMenuHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/blogmenu/blogmenu.scss b/src/addon/block/blogmenu/blogmenu.scss new file mode 100644 index 00000000000..c649986ed0e --- /dev/null +++ b/src/addon/block/blogmenu/blogmenu.scss @@ -0,0 +1,16 @@ +.addon-block-blog-menu core-block-pre-rendered { + .core-block-content { + ul.list { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li { + padding-bottom: 8px; + } + } + } + .core-block-footer { + display: none; + } +} \ No newline at end of file diff --git a/src/addon/block/blogmenu/lang/en.json b/src/addon/block/blogmenu/lang/en.json new file mode 100644 index 00000000000..23541f7a0b0 --- /dev/null +++ b/src/addon/block/blogmenu/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Blog menu" +} \ No newline at end of file diff --git a/src/addon/block/blogmenu/providers/block-handler.ts b/src/addon/block/blogmenu/providers/block-handler.ts new file mode 100644 index 00000000000..231137b8e0f --- /dev/null +++ b/src/addon/block/blogmenu/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockBlogMenuHandler extends CoreBlockBaseHandler { + name = 'AddonBlockBlogMenu'; + blockName = 'blog_menu'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_blogmenu.pluginname', + class: 'addon-block-blog-menu', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/blogrecent/blogrecent.module.ts b/src/addon/block/blogrecent/blogrecent.module.ts new file mode 100644 index 00000000000..1ecdf6f7b36 --- /dev/null +++ b/src/addon/block/blogrecent/blogrecent.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockBlogRecentHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockBlogRecentHandler + ] +}) +export class AddonBlockBlogRecentModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockBlogRecentHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/blogrecent/blogrecent.scss b/src/addon/block/blogrecent/blogrecent.scss new file mode 100644 index 00000000000..81a03b22766 --- /dev/null +++ b/src/addon/block/blogrecent/blogrecent.scss @@ -0,0 +1,13 @@ +.addon-block-blog-recent core-block-pre-rendered { + .core-block-content { + ul.list { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li { + padding-bottom: 8px; + } + } + } +} \ No newline at end of file diff --git a/src/addon/block/blogrecent/lang/en.json b/src/addon/block/blogrecent/lang/en.json new file mode 100644 index 00000000000..a92c0cce56e --- /dev/null +++ b/src/addon/block/blogrecent/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Recent blog entries" +} \ No newline at end of file diff --git a/src/addon/block/blogrecent/providers/block-handler.ts b/src/addon/block/blogrecent/providers/block-handler.ts new file mode 100644 index 00000000000..55f03cb8ed7 --- /dev/null +++ b/src/addon/block/blogrecent/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockBlogRecentHandler extends CoreBlockBaseHandler { + name = 'AddonBlockBlogRecent'; + blockName = 'blog_recent'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_blogrecent.pluginname', + class: 'addon-block-blog-recent', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/blogtags/blogtags.module.ts b/src/addon/block/blogtags/blogtags.module.ts new file mode 100644 index 00000000000..a9ed3a0904c --- /dev/null +++ b/src/addon/block/blogtags/blogtags.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockBlogTagsHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockBlogTagsHandler + ] +}) +export class AddonBlockBlogTagsModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockBlogTagsHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/blogtags/blogtags.scss b/src/addon/block/blogtags/blogtags.scss new file mode 100644 index 00000000000..a974b45cbd0 --- /dev/null +++ b/src/addon/block/blogtags/blogtags.scss @@ -0,0 +1,88 @@ +.addon-block-blog-tags core-block-pre-rendered { + .core-block-content { + ul.inline-list { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li { + padding: .2em; + display: inline-block; + + a { + @extend ion-badge; + @extend .badge-md; + text-decoration: none; + } + .s20 { + font-size: 1.5em; + font-weight: bold; + } + + .s19 { + font-size: 1.5em; + } + + .s18 { + font-size: 1.4em; + font-weight: bold; + } + + .s17 { + font-size: 1.4em; + } + + .s16 { + font-size: 1.3em; + font-weight: bold; + } + + .s15 { + font-size: 1.3em; + } + + .s14 { + font-size: 1.2em; + font-weight: bold; + } + + .s13 { + font-size: 1.2em; + } + + .s12, + .s11 { + font-size: 1.1em; + font-weight: bold; + } + + .s10, + .s9 { + font-size: 1.1em; + } + + .s8, + .s7 { + font-size: 1em; + font-weight: bold; + } + + .s6, + .s5 { + font-size: 1em; + } + + .s4, + .s3 { + font-size: 0.9em; + font-weight: bold; + } + + .s2, + .s1 { + font-size: 0.9em; + } + } + } + } +} \ No newline at end of file diff --git a/src/addon/block/blogtags/lang/en.json b/src/addon/block/blogtags/lang/en.json new file mode 100644 index 00000000000..683c3aa90af --- /dev/null +++ b/src/addon/block/blogtags/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Blog tags" +} \ No newline at end of file diff --git a/src/addon/block/blogtags/providers/block-handler.ts b/src/addon/block/blogtags/providers/block-handler.ts new file mode 100644 index 00000000000..aa2a17495c9 --- /dev/null +++ b/src/addon/block/blogtags/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockBlogTagsHandler extends CoreBlockBaseHandler { + name = 'AddonBlockBlogTags'; + blockName = 'blog_tags'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_blogtags.pluginname', + class: 'addon-block-blog-tags', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/calendarmonth/calendarmonth.module.ts b/src/addon/block/calendarmonth/calendarmonth.module.ts new file mode 100644 index 00000000000..e3204ae8f66 --- /dev/null +++ b/src/addon/block/calendarmonth/calendarmonth.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockCalendarMonthHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockCalendarMonthHandler + ] +}) +export class AddonBlockCalendarMonthModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockCalendarMonthHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/calendarmonth/lang/en.json b/src/addon/block/calendarmonth/lang/en.json new file mode 100644 index 00000000000..86a476c29e7 --- /dev/null +++ b/src/addon/block/calendarmonth/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Calendar" +} \ No newline at end of file diff --git a/src/addon/block/calendarmonth/providers/block-handler.ts b/src/addon/block/calendarmonth/providers/block-handler.ts new file mode 100644 index 00000000000..80e85edf8b1 --- /dev/null +++ b/src/addon/block/calendarmonth/providers/block-handler.ts @@ -0,0 +1,60 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; +import { AddonCalendarProvider } from '@addon/calendar/providers/calendar'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockCalendarMonthHandler extends CoreBlockBaseHandler { + name = 'AddonBlockCalendarMonth'; + blockName = 'calendar_month'; + + constructor(private calendarProvider: AddonCalendarProvider) { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + let link = 'AddonCalendarListPage'; + const linkParams: any = contextLevel == 'course' ? { courseId: instanceId } : {}; + + if (this.calendarProvider.canViewMonthInSite()) { + link = 'AddonCalendarIndexPage'; + } + + return { + title: 'addon.block_calendarmonth.pluginname', + class: 'addon-block-calendar-month', + component: CoreBlockOnlyTitleComponent, + link: link, + linkParams: linkParams + }; + } +} diff --git a/src/addon/block/calendarupcoming/calendarupcoming.module.ts b/src/addon/block/calendarupcoming/calendarupcoming.module.ts new file mode 100644 index 00000000000..345d1767223 --- /dev/null +++ b/src/addon/block/calendarupcoming/calendarupcoming.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockCalendarUpcomingHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockCalendarUpcomingHandler + ] +}) +export class AddonBlockCalendarUpcomingModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockCalendarUpcomingHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/calendarupcoming/lang/en.json b/src/addon/block/calendarupcoming/lang/en.json new file mode 100644 index 00000000000..4faba6dd20e --- /dev/null +++ b/src/addon/block/calendarupcoming/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": " Upcoming events" +} \ No newline at end of file diff --git a/src/addon/block/calendarupcoming/providers/block-handler.ts b/src/addon/block/calendarupcoming/providers/block-handler.ts new file mode 100644 index 00000000000..c58a0d08038 --- /dev/null +++ b/src/addon/block/calendarupcoming/providers/block-handler.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; +import { AddonCalendarProvider } from '@addon/calendar/providers/calendar'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockCalendarUpcomingHandler extends CoreBlockBaseHandler { + name = 'AddonBlockCalendarUpcoming'; + blockName = 'calendar_upcoming'; + + constructor(private calendarProvider: AddonCalendarProvider) { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + let link = 'AddonCalendarListPage'; + const linkParams: any = contextLevel == 'course' ? { courseId: instanceId } : {}; + + if (this.calendarProvider.canViewMonthInSite()) { + link = 'AddonCalendarIndexPage'; + linkParams.upcoming = true; + } + + return { + title: 'addon.block_calendarupcoming.pluginname', + class: 'addon-block-calendar-upcoming', + component: CoreBlockOnlyTitleComponent, + link: link, + linkParams: linkParams + }; + } +} diff --git a/src/addon/block/comments/comments.module.ts b/src/addon/block/comments/comments.module.ts new file mode 100644 index 00000000000..dce565e7da6 --- /dev/null +++ b/src/addon/block/comments/comments.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockCommentsHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockCommentsHandler + ] +}) +export class AddonBlockCommentsModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockCommentsHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/comments/lang/en.json b/src/addon/block/comments/lang/en.json new file mode 100644 index 00000000000..adcbcabae8f --- /dev/null +++ b/src/addon/block/comments/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Comments" +} \ No newline at end of file diff --git a/src/addon/block/comments/providers/block-handler.ts b/src/addon/block/comments/providers/block-handler.ts new file mode 100644 index 00000000000..ada6e16549d --- /dev/null +++ b/src/addon/block/comments/providers/block-handler.ts @@ -0,0 +1,53 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockCommentsHandler extends CoreBlockBaseHandler { + name = 'AddonBlockComments'; + blockName = 'comments'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_comments.pluginname', + class: 'addon-block-comments', + component: CoreBlockOnlyTitleComponent, + link: 'CoreCommentsViewerPage', + linkParams: { contextLevel: contextLevel, instanceId: instanceId, + componentName: 'block_comments', area: 'page_comments', itemId: 0 } + }; + } +} diff --git a/src/addon/block/completionstatus/completionstatus.module.ts b/src/addon/block/completionstatus/completionstatus.module.ts new file mode 100644 index 00000000000..37d448a93b2 --- /dev/null +++ b/src/addon/block/completionstatus/completionstatus.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockCompletionStatusHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockCompletionStatusHandler + ] +}) +export class AddonBlockCompletionStatusModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockCompletionStatusHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/completionstatus/lang/en.json b/src/addon/block/completionstatus/lang/en.json new file mode 100644 index 00000000000..fe57356daba --- /dev/null +++ b/src/addon/block/completionstatus/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Course completion status" +} \ No newline at end of file diff --git a/src/addon/block/completionstatus/providers/block-handler.ts b/src/addon/block/completionstatus/providers/block-handler.ts new file mode 100644 index 00000000000..88fca4ec859 --- /dev/null +++ b/src/addon/block/completionstatus/providers/block-handler.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockCompletionStatusHandler extends CoreBlockBaseHandler { + name = 'AddonBlockCompletionStatus'; + blockName = 'completionstatus'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_completionstatus.pluginname', + class: 'addon-block-completion-status', + component: CoreBlockOnlyTitleComponent, + link: 'AddonCourseCompletionReportPage', + linkParams: { courseId: instanceId } + }; + } +} diff --git a/src/addon/block/glossaryrandom/glossaryrandom.module.ts b/src/addon/block/glossaryrandom/glossaryrandom.module.ts new file mode 100644 index 00000000000..e6d23895706 --- /dev/null +++ b/src/addon/block/glossaryrandom/glossaryrandom.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockGlossaryRandomHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockGlossaryRandomHandler + ] +}) +export class AddonBlockGlossaryRandomModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockGlossaryRandomHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/glossaryrandom/lang/en.json b/src/addon/block/glossaryrandom/lang/en.json new file mode 100644 index 00000000000..1ae4de38c6e --- /dev/null +++ b/src/addon/block/glossaryrandom/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Random glossary entry" +} \ No newline at end of file diff --git a/src/addon/block/glossaryrandom/providers/block-handler.ts b/src/addon/block/glossaryrandom/providers/block-handler.ts new file mode 100644 index 00000000000..d639ccb16fa --- /dev/null +++ b/src/addon/block/glossaryrandom/providers/block-handler.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockGlossaryRandomHandler extends CoreBlockBaseHandler { + name = 'AddonBlockGlossaryRandom'; + blockName = 'glossary_random'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + return { + title: block.contents.title || 'addon.block_glossaryrandom.pluginname', + class: 'addon-block-glossary-random', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/html/html.module.ts b/src/addon/block/html/html.module.ts new file mode 100644 index 00000000000..1b81cf4adb1 --- /dev/null +++ b/src/addon/block/html/html.module.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockHtmlHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule + ], + exports: [ + ], + providers: [ + AddonBlockHtmlHandler + ] +}) +export class AddonBlockHtmlModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockHtmlHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/html/providers/block-handler.ts b/src/addon/block/html/providers/block-handler.ts new file mode 100644 index 00000000000..a5603fb53fc --- /dev/null +++ b/src/addon/block/html/providers/block-handler.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockHtmlHandler extends CoreBlockBaseHandler { + name = 'AddonBlockHtml'; + blockName = 'html'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: block.contents.title, + class: 'addon-block-html', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/learningplans/lang/en.json b/src/addon/block/learningplans/lang/en.json new file mode 100644 index 00000000000..0a7f81e2282 --- /dev/null +++ b/src/addon/block/learningplans/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Learning plans" +} \ No newline at end of file diff --git a/src/addon/block/learningplans/learningplans.module.ts b/src/addon/block/learningplans/learningplans.module.ts new file mode 100644 index 00000000000..167178d7014 --- /dev/null +++ b/src/addon/block/learningplans/learningplans.module.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockLearningPlansHandler } from './providers/block-handler'; +import { CoreBlockComponentsModule } from '@core/block/components/components.module'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + CoreBlockComponentsModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockLearningPlansHandler + ] +}) +export class AddonBlockLearningPlansModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockLearningPlansHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/learningplans/providers/block-handler.ts b/src/addon/block/learningplans/providers/block-handler.ts new file mode 100644 index 00000000000..fa98be63855 --- /dev/null +++ b/src/addon/block/learningplans/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockLearningPlansHandler extends CoreBlockBaseHandler { + name = 'AddonBlockLearningPlans'; + blockName = 'lp'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_learningplans.pluginname', + class: 'addon-block-learning-plans', + component: CoreBlockOnlyTitleComponent, + link: 'AddonCompetencyPlanListPage' + }; + } +} diff --git a/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html b/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html index 598c204f0ed..294a499c44a 100644 --- a/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html +++ b/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html @@ -19,11 +19,11 @@

{{ 'addon.block_myoverview.pluginname' | translate }}

{{ 'addon.block_myoverview.all' | translate }}∫ - {{ 'addon.block_myoverview.inprogress' | translate }} - {{ 'addon.block_myoverview.future' | translate }} - {{ 'addon.block_myoverview.past' | translate }} - {{ 'addon.block_myoverview.favourites' | translate }} - {{ 'addon.block_myoverview.hiddencourses' | translate }} + {{ 'addon.block_myoverview.inprogress' | translate }} + {{ 'addon.block_myoverview.future' | translate }} + {{ 'addon.block_myoverview.past' | translate }} + {{ 'addon.block_myoverview.favourites' | translate }} + {{ 'addon.block_myoverview.hiddencourses' | translate }} diff --git a/src/addon/block/myoverview/components/myoverview/myoverview.ts b/src/addon/block/myoverview/components/myoverview/myoverview.ts index b36202f7e09..ed68b920586 100644 --- a/src/addon/block/myoverview/components/myoverview/myoverview.ts +++ b/src/addon/block/myoverview/components/myoverview/myoverview.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, Input, OnDestroy, ViewChild, Injector } from '@angular/core'; +import { Component, OnInit, Input, OnDestroy, ViewChild, Injector, OnChanges, SimpleChange } from '@angular/core'; import { Searchbar } from 'ionic-angular'; import { CoreEventsProvider } from '@providers/events'; import { CoreUtilsProvider } from '@providers/utils/utils'; @@ -32,7 +32,7 @@ import { CoreBlockBaseComponent } from '@core/block/classes/base-block-component selector: 'addon-block-myoverview', templateUrl: 'addon-block-myoverview.html' }) -export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy { +export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy { @ViewChild('searchbar') searchbar: Searchbar; @Input() downloadEnabled: boolean; @@ -64,10 +64,14 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem showSortFilter = false; downloadCourseEnabled: boolean; downloadCoursesEnabled: boolean; + disableInProgress = false; + disablePast = false; + disableFuture = false; + disableFavourite = false; + disableHidden = false; protected prefetchIconsInitialized = false; protected isDestroyed; - protected downloadButtonObserver; protected coursesObserver; protected updateSiteObserver; protected courseIds = []; @@ -87,18 +91,6 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem */ ngOnInit(): void { // Refresh the enabled flags if enabled. - this.downloadButtonObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, - (data) => { - const wasEnabled = this.downloadEnabled; - - this.downloadEnabled = data.enabled; - - if (!wasEnabled && this.downloadEnabled && this.loaded) { - // Download all courses is enabled now, initialize it. - this.initPrefetchCoursesIcons(); - } - }); - this.downloadCourseEnabled = !this.coursesProvider.isDownloadCourseDisabledInSite(); this.downloadCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); @@ -128,6 +120,16 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem }); } + /** + * 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. * @@ -173,12 +175,17 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem this.courses.filter = ''; this.showFilter = false; + this.disableInProgress = this.courses.inprogress.length === 0; + this.disablePast = this.courses.past.length === 0; + this.disableFuture = this.courses.future.length === 0; this.showSelectorFilter = courses.length > 0 && (this.courses.past.length > 0 || this.courses.future.length > 0 || - typeof courses[0].enddate != 'undefined'); + typeof courses[0].enddate != 'undefined'); this.showHidden = this.showSelectorFilter && typeof courses[0].hidden != 'undefined'; + this.disableHidden = this.courses.hidden.length === 0; this.showFavourite = this.showSelectorFilter && typeof courses[0].isfavourite != 'undefined'; - if (!this.showSelectorFilter) { - // No selector, show all. + this.disableFavourite = this.courses.favourite.length === 0; + if (!this.showSelectorFilter || (this.selectedFilter === 'inprogress' && this.disableInProgress)) { + // No selector, or the default option is disabled, show all. this.selectedFilter = 'all'; } this.filteredCourses = this.courses[this.selectedFilter]; @@ -350,6 +357,5 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem this.isDestroyed = true; this.coursesObserver && this.coursesObserver.off(); this.updateSiteObserver && this.updateSiteObserver.off(); - this.downloadButtonObserver && this.downloadButtonObserver.off(); } } diff --git a/src/addon/block/myoverview/providers/block-handler.ts b/src/addon/block/myoverview/providers/block-handler.ts index 5f723467cfd..e0a593b9a18 100644 --- a/src/addon/block/myoverview/providers/block-handler.ts +++ b/src/addon/block/myoverview/providers/block-handler.ts @@ -50,7 +50,7 @@ export class AddonBlockMyOverviewHandler extends CoreBlockBaseHandler { * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/block/newsitems/lang/en.json b/src/addon/block/newsitems/lang/en.json new file mode 100644 index 00000000000..83b98129739 --- /dev/null +++ b/src/addon/block/newsitems/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Latest announcements" +} \ No newline at end of file diff --git a/src/addon/block/newsitems/newsitems.module.ts b/src/addon/block/newsitems/newsitems.module.ts new file mode 100644 index 00000000000..a3364ea5ea0 --- /dev/null +++ b/src/addon/block/newsitems/newsitems.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockNewsItemsHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockNewsItemsHandler + ] +}) +export class AddonBlockNewsItemsModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockNewsItemsHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/newsitems/newsitems.scss b/src/addon/block/newsitems/newsitems.scss new file mode 100644 index 00000000000..8b0c46287b9 --- /dev/null +++ b/src/addon/block/newsitems/newsitems.scss @@ -0,0 +1,26 @@ +.addon-block-news-items core-block-pre-rendered { + .core-block-content { + .unlist { + list-style-type: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li.post { + padding-bottom: 16px; + } + li.post:last-child { + padding-bottom: 0; + } + } + } + + // Hide RSS link. + .core-block-footer { + a { + display: none; + } + a:first-child { + display: inline; + } + } +} \ No newline at end of file diff --git a/src/addon/block/newsitems/providers/block-handler.ts b/src/addon/block/newsitems/providers/block-handler.ts new file mode 100644 index 00000000000..c077474d8a0 --- /dev/null +++ b/src/addon/block/newsitems/providers/block-handler.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockNewsItemsHandler extends CoreBlockBaseHandler { + name = 'AddonBlockNewsItems'; + blockName = 'news_items'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + return { + title: 'addon.block_newsitems.pluginname', + class: 'addon-block-news-items', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/onlineusers/lang/en.json b/src/addon/block/onlineusers/lang/en.json new file mode 100644 index 00000000000..4bc6cd41263 --- /dev/null +++ b/src/addon/block/onlineusers/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Online users" +} \ No newline at end of file diff --git a/src/addon/block/onlineusers/onlineusers.module.ts b/src/addon/block/onlineusers/onlineusers.module.ts new file mode 100644 index 00000000000..0df61ad7a1f --- /dev/null +++ b/src/addon/block/onlineusers/onlineusers.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockOnlineUsersHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockOnlineUsersHandler + ] +}) +export class AddonBlockOnlineUsersModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockOnlineUsersHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/onlineusers/onlineusers.scss b/src/addon/block/onlineusers/onlineusers.scss new file mode 100644 index 00000000000..009a347b807 --- /dev/null +++ b/src/addon/block/onlineusers/onlineusers.scss @@ -0,0 +1,48 @@ +.addon-block-online-users core-block-pre-rendered .core-block-content { + max-height: 200px; + overflow-y: auto; + .item-inner, + .input-wrapper { + overflow-y: visible; + align-self: start; + } + + .list { + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li.listentry { + clear: both; + list-style-type: none; + + .user { + @include float(start); + position: relative; + padding-bottom: 16px; + + .core-adapted-img-container { + display: inline; + @include margin-horizontal(0, 8px); + } + + .userpicture { + vertical-align: text-bottom; + } + } + + .message { + @include float(end); + margin-top: 3px; + } + + .uservisibility { // No support on the app. + display: none; + } + } + } + + .info { + text-align: center; + } + +} \ No newline at end of file diff --git a/src/addon/block/onlineusers/providers/block-handler.ts b/src/addon/block/onlineusers/providers/block-handler.ts new file mode 100644 index 00000000000..353835c830c --- /dev/null +++ b/src/addon/block/onlineusers/providers/block-handler.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockOnlineUsersHandler extends CoreBlockBaseHandler { + name = 'AddonBlockOnlineUsers'; + blockName = 'online_users'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + return { + title: 'addon.block_onlineusers.pluginname', + class: 'addon-block-online-users', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/privatefiles/lang/en.json b/src/addon/block/privatefiles/lang/en.json new file mode 100644 index 00000000000..bba9d4bc04c --- /dev/null +++ b/src/addon/block/privatefiles/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Private files" +} \ No newline at end of file diff --git a/src/addon/block/privatefiles/privatefiles.module.ts b/src/addon/block/privatefiles/privatefiles.module.ts new file mode 100644 index 00000000000..c9617c3724c --- /dev/null +++ b/src/addon/block/privatefiles/privatefiles.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockPrivateFilesHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockPrivateFilesHandler + ] +}) +export class AddonBlockPrivateFilesModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockPrivateFilesHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/privatefiles/providers/block-handler.ts b/src/addon/block/privatefiles/providers/block-handler.ts new file mode 100644 index 00000000000..f0284df9747 --- /dev/null +++ b/src/addon/block/privatefiles/providers/block-handler.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockPrivateFilesHandler extends CoreBlockBaseHandler { + name = 'AddonBlockPrivateFiles'; + blockName = 'private_files'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_privatefiles.pluginname', + class: 'addon-block-private-files', + component: CoreBlockOnlyTitleComponent, + link: 'AddonFilesListPage', + linkParams: {root: 'my'} + }; + } +} diff --git a/src/addon/block/recentactivity/lang/en.json b/src/addon/block/recentactivity/lang/en.json new file mode 100644 index 00000000000..29f7996e2a6 --- /dev/null +++ b/src/addon/block/recentactivity/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Recent activity" +} \ No newline at end of file diff --git a/src/addon/block/recentactivity/providers/block-handler.ts b/src/addon/block/recentactivity/providers/block-handler.ts new file mode 100644 index 00000000000..ac69af02b3f --- /dev/null +++ b/src/addon/block/recentactivity/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockRecentActivityHandler extends CoreBlockBaseHandler { + name = 'AddonBlockRecentActivity'; + blockName = 'recent_activity'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_recentactivity.pluginname', + class: 'addon-block-recent-activity', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/recentactivity/recentactivity.module.ts b/src/addon/block/recentactivity/recentactivity.module.ts new file mode 100644 index 00000000000..cd0136763ef --- /dev/null +++ b/src/addon/block/recentactivity/recentactivity.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockRecentActivityHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockRecentActivityHandler + ] +}) +export class AddonBlockRecentActivityModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockRecentActivityHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/recentactivity/recentactivity.scss b/src/addon/block/recentactivity/recentactivity.scss new file mode 100644 index 00000000000..0eb73e10255 --- /dev/null +++ b/src/addon/block/recentactivity/recentactivity.scss @@ -0,0 +1,20 @@ +.addon-block-recent-activity core-block-pre-rendered { + .core-block-content { + .activitydate, .activityhead { + text-align: center; + } + + .unlist { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + li { + margin-bottom: 1em; + + .head .date { + @include float(end); + } + } + } + } +} \ No newline at end of file diff --git a/src/addon/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts b/src/addon/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts index 2d962d17cfc..92697e4f0b7 100644 --- a/src/addon/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts +++ b/src/addon/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, OnDestroy, Injector, Input } from '@angular/core'; +import { Component, OnInit, OnDestroy, Injector, Input, OnChanges, SimpleChange } from '@angular/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitesProvider } from '@providers/sites'; @@ -30,7 +30,7 @@ import { CoreBlockBaseComponent } from '@core/block/classes/base-block-component selector: 'addon-block-recentlyaccessedcourses', templateUrl: 'addon-block-recentlyaccessedcourses.html' }) -export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy { +export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy { @Input() downloadEnabled: boolean; courses = []; @@ -41,7 +41,6 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom protected prefetchIconsInitialized = false; protected isDestroyed; - protected downloadButtonObserver; protected coursesObserver; protected courseIds = []; protected fetchContentDefaultError = 'Error getting recent courses data.'; @@ -59,18 +58,6 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom * Component being initialized. */ ngOnInit(): void { - // Refresh the enabled flags if enabled. - this.downloadButtonObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, - (data) => { - const wasEnabled = this.downloadEnabled; - - this.downloadEnabled = data.enabled; - - if (!wasEnabled && this.downloadEnabled && this.loaded) { - // Download all courses is enabled now, initialize it. - this.initPrefetchCoursesIcons(); - } - }); this.coursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, () => { this.refreshContent(); @@ -79,6 +66,16 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom 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. * @@ -155,6 +152,5 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom ngOnDestroy(): void { this.isDestroyed = true; this.coursesObserver && this.coursesObserver.off(); - this.downloadButtonObserver && this.downloadButtonObserver.off(); } } diff --git a/src/addon/block/recentlyaccessedcourses/providers/block-handler.ts b/src/addon/block/recentlyaccessedcourses/providers/block-handler.ts index a1117bd5d6b..3af9cf0dd36 100644 --- a/src/addon/block/recentlyaccessedcourses/providers/block-handler.ts +++ b/src/addon/block/recentlyaccessedcourses/providers/block-handler.ts @@ -38,7 +38,7 @@ export class AddonBlockRecentlyAccessedCoursesHandler extends CoreBlockBaseHandl * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/block/recentlyaccesseditems/providers/block-handler.ts b/src/addon/block/recentlyaccesseditems/providers/block-handler.ts index 2963017e876..dfadcc54271 100644 --- a/src/addon/block/recentlyaccesseditems/providers/block-handler.ts +++ b/src/addon/block/recentlyaccesseditems/providers/block-handler.ts @@ -38,7 +38,7 @@ export class AddonBlockRecentlyAccessedItemsHandler extends CoreBlockBaseHandler * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/block/rssclient/lang/en.json b/src/addon/block/rssclient/lang/en.json new file mode 100644 index 00000000000..18282971b14 --- /dev/null +++ b/src/addon/block/rssclient/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Remote RSS feeds" +} \ No newline at end of file diff --git a/src/addon/block/rssclient/providers/block-handler.ts b/src/addon/block/rssclient/providers/block-handler.ts new file mode 100644 index 00000000000..8976f218250 --- /dev/null +++ b/src/addon/block/rssclient/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockRssClientHandler extends CoreBlockBaseHandler { + name = 'AddonBlockRssClient'; + blockName = 'rss_client'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: block.contents.title || 'addon.block_rssclient.pluginname', + class: 'addon-block-rss-client', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/rssclient/rssclient.module.ts b/src/addon/block/rssclient/rssclient.module.ts new file mode 100644 index 00000000000..b98c39ae544 --- /dev/null +++ b/src/addon/block/rssclient/rssclient.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockRssClientHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockRssClientHandler + ] +}) +export class AddonBlockRssClientModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockRssClientHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/rssclient/rssclient.scss b/src/addon/block/rssclient/rssclient.scss new file mode 100644 index 00000000000..33b37b0e75d --- /dev/null +++ b/src/addon/block/rssclient/rssclient.scss @@ -0,0 +1,19 @@ +.addon-block-rss-client core-block-pre-rendered { + .core-block-content { + .list { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li { + border-top: 1px solid $gray; + padding: 5px; + padding-bottom: 8px; + } + + li:first-child { + border-top-width: 0; + } + } + } +} \ No newline at end of file diff --git a/src/addon/block/selfcompletion/lang/en.json b/src/addon/block/selfcompletion/lang/en.json new file mode 100644 index 00000000000..32521695a4b --- /dev/null +++ b/src/addon/block/selfcompletion/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Self completion" +} \ No newline at end of file diff --git a/src/addon/block/selfcompletion/providers/block-handler.ts b/src/addon/block/selfcompletion/providers/block-handler.ts new file mode 100644 index 00000000000..57d716e5a41 --- /dev/null +++ b/src/addon/block/selfcompletion/providers/block-handler.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockOnlyTitleComponent } from '@core/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockSelfCompletionHandler extends CoreBlockBaseHandler { + name = 'AddonBlockSelfCompletion'; + blockName = 'selfcompletion'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_selfcompletion.pluginname', + class: 'addon-block-self-completion', + component: CoreBlockOnlyTitleComponent, + link: 'AddonCourseCompletionReportPage', + linkParams: { courseId: instanceId } + }; + } +} diff --git a/src/addon/block/selfcompletion/selfcompletion.module.ts b/src/addon/block/selfcompletion/selfcompletion.module.ts new file mode 100644 index 00000000000..b101210b2d7 --- /dev/null +++ b/src/addon/block/selfcompletion/selfcompletion.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockSelfCompletionHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockSelfCompletionHandler + ] +}) +export class AddonBlockSelfCompletionModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockSelfCompletionHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html b/src/addon/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html index e1a3c10a527..f9a5b7f825d 100644 --- a/src/addon/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html +++ b/src/addon/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html @@ -2,9 +2,11 @@

{{ 'addon.block_sitemainmenu.pluginname' | translate }}

- - - + + + + - + + diff --git a/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts b/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts index 0f335521904..23bfb74ca8f 100644 --- a/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts +++ b/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, Injector } from '@angular/core'; +import { Component, OnInit, Injector, Input } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; @@ -28,7 +28,9 @@ import { CoreBlockBaseComponent } from '@core/block/classes/base-block-component templateUrl: 'addon-block-sitemainmenu.html' }) export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent implements OnInit { - block: any; + @Input() downloadEnabled: boolean; + + mainMenuBlock: any; siteHomeId: number; protected fetchContentDefaultError = 'Error getting main menu data.'; @@ -60,9 +62,9 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl promises.push(this.courseProvider.invalidateSections(this.siteHomeId)); promises.push(this.siteHomeProvider.invalidateNewsForum(this.siteHomeId)); - if (this.block && this.block.modules) { + if (this.mainMenuBlock && this.mainMenuBlock.modules) { // Invalidate modules prefetch data. - promises.push(this.prefetchDelegate.invalidateModules(this.block.modules, this.siteHomeId)); + promises.push(this.prefetchDelegate.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId)); } return Promise.all(promises); @@ -75,11 +77,11 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl */ protected fetchContent(): Promise { return this.courseProvider.getSections(this.siteHomeId, false, true).then((sections) => { - this.block = sections.find((section) => section.section == 0); + this.mainMenuBlock = sections.find((section) => section.section == 0); - if (this.block) { - this.block.hasContent = this.courseHelper.sectionHasContent(this.block); - this.courseHelper.addHandlerDataForModules([this.block], this.siteHomeId); + if (this.mainMenuBlock) { + this.mainMenuBlock.hasContent = this.courseHelper.sectionHasContent(this.mainMenuBlock); + this.courseHelper.addHandlerDataForModules([this.mainMenuBlock], this.siteHomeId); // Check if Site Home displays announcements. If so, remove it from the main menu block. const currentSite = this.sitesProvider.getCurrentSite(), @@ -92,15 +94,15 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl hasNewsItem = items.find((item) => { return item == '0'; }); } - if (hasNewsItem && this.block.modules) { + if (hasNewsItem && this.mainMenuBlock.modules) { // Remove forum activity (news one only) from the main menu block to prevent duplicates. return this.siteHomeProvider.getNewsForum(this.siteHomeId).then((forum) => { // Search the module that belongs to site news. - for (let i = 0; i < this.block.modules.length; i++) { - const module = this.block.modules[i]; + for (let i = 0; i < this.mainMenuBlock.modules.length; i++) { + const module = this.mainMenuBlock.modules[i]; if (module.modname == 'forum' && module.instance == forum.id) { - this.block.modules.splice(i, 1); + this.mainMenuBlock.modules.splice(i, 1); break; } } diff --git a/src/addon/block/sitemainmenu/providers/block-handler.ts b/src/addon/block/sitemainmenu/providers/block-handler.ts index 8c699aff0ea..dcaf4fe01a4 100644 --- a/src/addon/block/sitemainmenu/providers/block-handler.ts +++ b/src/addon/block/sitemainmenu/providers/block-handler.ts @@ -38,7 +38,7 @@ export class AddonBlockSiteMainMenuHandler extends CoreBlockBaseHandler { * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/block/starredcourses/components/starredcourses/starredcourses.ts b/src/addon/block/starredcourses/components/starredcourses/starredcourses.ts index ffa235134ec..ca25f3354be 100644 --- a/src/addon/block/starredcourses/components/starredcourses/starredcourses.ts +++ b/src/addon/block/starredcourses/components/starredcourses/starredcourses.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, OnDestroy, Injector, Input } from '@angular/core'; +import { Component, OnInit, OnDestroy, Injector, Input, OnChanges, SimpleChange } from '@angular/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitesProvider } from '@providers/sites'; @@ -30,7 +30,7 @@ import { CoreBlockBaseComponent } from '@core/block/classes/base-block-component selector: 'addon-block-starredcourses', templateUrl: 'addon-block-starredcourses.html' }) -export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy { +export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy { @Input() downloadEnabled: boolean; courses = []; @@ -41,7 +41,6 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im protected prefetchIconsInitialized = false; protected isDestroyed; - protected downloadButtonObserver; protected coursesObserver; protected courseIds = []; protected fetchContentDefaultError = 'Error getting starred courses data.'; @@ -59,18 +58,6 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im * Component being initialized. */ ngOnInit(): void { - // Refresh the enabled flags if enabled. - this.downloadButtonObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, - (data) => { - const wasEnabled = this.downloadEnabled; - - this.downloadEnabled = data.enabled; - - if (!wasEnabled && this.downloadEnabled && this.loaded) { - // Download all courses is enabled now, initialize it. - this.initPrefetchCoursesIcons(); - } - }); this.coursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, () => { this.refreshContent(); @@ -79,6 +66,16 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im 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. * @@ -155,6 +152,5 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im ngOnDestroy(): void { this.isDestroyed = true; this.coursesObserver && this.coursesObserver.off(); - this.downloadButtonObserver && this.downloadButtonObserver.off(); } } diff --git a/src/addon/block/starredcourses/providers/block-handler.ts b/src/addon/block/starredcourses/providers/block-handler.ts index 7675876b127..c7abc34854a 100644 --- a/src/addon/block/starredcourses/providers/block-handler.ts +++ b/src/addon/block/starredcourses/providers/block-handler.ts @@ -38,7 +38,7 @@ export class AddonBlockStarredCoursesHandler extends CoreBlockBaseHandler { * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/block/tags/lang/en.json b/src/addon/block/tags/lang/en.json new file mode 100644 index 00000000000..a4080dd788f --- /dev/null +++ b/src/addon/block/tags/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Tags" +} \ No newline at end of file diff --git a/src/addon/block/tags/providers/block-handler.ts b/src/addon/block/tags/providers/block-handler.ts new file mode 100644 index 00000000000..f1c7d4cd83e --- /dev/null +++ b/src/addon/block/tags/providers/block-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; + +import { CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockBaseHandler } from '@core/block/classes/base-block-handler'; + +/** + * Block handler. + */ +@Injectable() +export class AddonBlockTagsHandler extends CoreBlockBaseHandler { + name = 'AddonBlockTags'; + blockName = 'tags'; + + constructor() { + super(); + } + + /** + * Returns the data needed to render the block. + * + * @param {Injector} injector Injector. + * @param {any} block The block to render. + * @param {string} contextLevel The context where the block will be used. + * @param {number} instanceId The instance ID associated with the context level. + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) + : CoreBlockHandlerData | Promise { + + return { + title: 'addon.block_tags.pluginname', + class: 'addon-block-tags', + component: CoreBlockPreRenderedComponent + }; + } +} diff --git a/src/addon/block/tags/tags.module.ts b/src/addon/block/tags/tags.module.ts new file mode 100644 index 00000000000..4b57911c0f0 --- /dev/null +++ b/src/addon/block/tags/tags.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { AddonBlockTagsHandler } from './providers/block-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule, + TranslateModule.forChild() + ], + exports: [ + ], + providers: [ + AddonBlockTagsHandler + ] +}) +export class AddonBlockTagsModule { + constructor(blockDelegate: CoreBlockDelegate, blockHandler: AddonBlockTagsHandler) { + blockDelegate.registerHandler(blockHandler); + } +} diff --git a/src/addon/block/tags/tags.scss b/src/addon/block/tags/tags.scss new file mode 100644 index 00000000000..f4c54d1673c --- /dev/null +++ b/src/addon/block/tags/tags.scss @@ -0,0 +1,106 @@ +.addon-block-tags core-block-pre-rendered { + .core-block-content { + .tag_cloud { + text-align: center; + ul.inline-list { + list-style: none; + @include margin-horizontal(0); + -webkit-padding-start: 0; + + li { + padding: .2em; + display: inline-block; + + a { + @extend ion-badge; + @extend .badge-md; + text-decoration: none; + } + .s20 { + font-size: 2.7em; + } + + .s19 { + font-size: 2.6em; + } + + .s18 { + font-size: 2.5em; + } + + .s17 { + font-size: 2.4em; + } + + .s16 { + font-size: 2.3em; + } + + .s15 { + font-size: 2.2em; + } + + .s14 { + font-size: 2.1em; + } + + .s13 { + font-size: 2em; + } + + .s12 { + font-size: 1.9em; + } + + .s11 { + font-size: 1.8em; + } + + .s10 { + font-size: 1.7em; + } + + .s9 { + font-size: 1.6em; + } + + .s8 { + font-size: 1.5em; + } + + .s7 { + font-size: 1.4em; + } + + .s6 { + font-size: 1.3em; + } + + .s5 { + font-size: 1.2em; + } + + .s4 { + font-size: 1.1em; + } + + .s3 { + font-size: 1em; + } + + .s2 { + font-size: 0.9em; + } + + .s1 { + font-size: 0.8em; + } + + .s0 { + font-size: 0.7em; + } + } + } + } + } +} \ No newline at end of file diff --git a/src/addon/block/timeline/providers/block-handler.ts b/src/addon/block/timeline/providers/block-handler.ts index 2e89d60fbe5..8973fbc5dae 100644 --- a/src/addon/block/timeline/providers/block-handler.ts +++ b/src/addon/block/timeline/providers/block-handler.ts @@ -55,7 +55,7 @@ export class AddonBlockTimelineHandler extends CoreBlockBaseHandler { * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { return { diff --git a/src/addon/blog/blog.module.ts b/src/addon/blog/blog.module.ts index f3137274535..b697ba27a3d 100644 --- a/src/addon/blog/blog.module.ts +++ b/src/addon/blog/blog.module.ts @@ -17,12 +17,14 @@ import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; import { CoreUserDelegate } from '@core/user/providers/user-delegate'; import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; import { AddonBlogProvider } from './providers/blog'; import { AddonBlogMainMenuHandler } from './providers/mainmenu-handler'; import { AddonBlogUserHandler } from './providers/user-handler'; import { AddonBlogCourseOptionHandler } from './providers/course-option-handler'; import { AddonBlogComponentsModule } from './components/components.module'; import { AddonBlogIndexLinkHandler } from './providers/index-link-handler'; +import { AddonBlogTagAreaHandler } from './providers/tag-area-handler'; @NgModule({ declarations: [ @@ -35,17 +37,20 @@ import { AddonBlogIndexLinkHandler } from './providers/index-link-handler'; AddonBlogMainMenuHandler, AddonBlogUserHandler, AddonBlogCourseOptionHandler, - AddonBlogIndexLinkHandler + AddonBlogIndexLinkHandler, + AddonBlogTagAreaHandler ] }) export class AddonBlogModule { constructor(mainMenuDelegate: CoreMainMenuDelegate, menuHandler: AddonBlogMainMenuHandler, userHandler: AddonBlogUserHandler, userDelegate: CoreUserDelegate, courseOptionHandler: AddonBlogCourseOptionHandler, courseOptionsDelegate: CoreCourseOptionsDelegate, - linkHandler: AddonBlogIndexLinkHandler, contentLinksDelegate: CoreContentLinksDelegate) { + linkHandler: AddonBlogIndexLinkHandler, contentLinksDelegate: CoreContentLinksDelegate, + tagAreaDelegate: CoreTagAreaDelegate, tagAreaHandler: AddonBlogTagAreaHandler) { mainMenuDelegate.registerHandler(menuHandler); userDelegate.registerHandler(userHandler); courseOptionsDelegate.registerHandler(courseOptionHandler); contentLinksDelegate.registerHandler(linkHandler); + tagAreaDelegate.registerHandler(tagAreaHandler); } } diff --git a/src/addon/blog/components/components.module.ts b/src/addon/blog/components/components.module.ts index 0e56fcc3fda..efba08d7d7b 100644 --- a/src/addon/blog/components/components.module.ts +++ b/src/addon/blog/components/components.module.ts @@ -20,6 +20,7 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCommentsComponentsModule } from '@core/comments/components/components.module'; +import { CoreTagComponentsModule } from '@core/tag/components/components.module'; import { AddonBlogEntriesComponent } from './entries/entries'; @NgModule({ @@ -33,7 +34,8 @@ import { AddonBlogEntriesComponent } from './entries/entries'; CoreComponentsModule, CoreDirectivesModule, CorePipesModule, - CoreCommentsComponentsModule + CoreCommentsComponentsModule, + CoreTagComponentsModule ], providers: [ ], diff --git a/src/addon/blog/components/entries/addon-blog-entries.html b/src/addon/blog/components/entries/addon-blog-entries.html index 670e4d276c1..b3a41ae72b6 100644 --- a/src/addon/blog/components/entries/addon-blog-entries.html +++ b/src/addon/blog/components/entries/addon-blog-entries.html @@ -4,9 +4,9 @@
- + {{ 'addon.blog.showonlyyourentries' | translate }} - + >
@@ -29,6 +29,10 @@

+ +
{{ 'core.tag.tags' | translate }}:
+ +
diff --git a/src/addon/blog/components/entries/entries.ts b/src/addon/blog/components/entries/entries.ts index b66db02a32b..aa66beaef40 100644 --- a/src/addon/blog/components/entries/entries.ts +++ b/src/addon/blog/components/entries/entries.ts @@ -15,10 +15,12 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core'; import { Content } from 'ionic-angular'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonBlogProvider } from '../../providers/blog'; import { CoreCommentsProvider } from '@core/comments/providers/comments'; +import { CoreTagProvider } from '@core/tag/providers/tag'; /** * Component that displays the blog entries. @@ -37,6 +39,9 @@ export class AddonBlogEntriesComponent implements OnInit { protected filter = {}; protected pageLoaded = 0; + protected userPageLoaded = 0; + protected canLoadMoreEntries = false; + protected canLoadMoreUserEntries = true; @ViewChild(Content) content: Content; @@ -45,14 +50,15 @@ export class AddonBlogEntriesComponent implements OnInit { loadMoreError = false; entries = []; currentUserId: number; - showMyIssuesToggle = false; + showMyEntriesToggle = false; onlyMyEntries = false; component = AddonBlogProvider.COMPONENT; commentsEnabled: boolean; + tagsEnabled: boolean; constructor(protected blogProvider: AddonBlogProvider, protected domUtils: CoreDomUtilsProvider, - protected userProvider: CoreUserProvider, sitesProvider: CoreSitesProvider, - protected commentsProvider: CoreCommentsProvider) { + protected userProvider: CoreUserProvider, sitesProvider: CoreSitesProvider, protected utils: CoreUtilsProvider, + protected commentsProvider: CoreCommentsProvider, private tagProvider: CoreTagProvider) { this.currentUserId = sitesProvider.getCurrentSiteUserId(); } @@ -63,6 +69,7 @@ export class AddonBlogEntriesComponent implements OnInit { if (this.userId) { this.filter['userid'] = this.userId; } + this.showMyEntriesToggle = !this.userId; if (this.courseId) { this.filter['courseid'] = this.courseId; @@ -85,6 +92,7 @@ export class AddonBlogEntriesComponent implements OnInit { } this.commentsEnabled = !this.commentsProvider.areCommentsDisabledInSite(); + this.tagsEnabled = this.tagProvider.areTagsAvailableInSite(); this.fetchEntries().then(() => { this.blogProvider.logView(this.filter).catch(() => { @@ -104,9 +112,12 @@ export class AddonBlogEntriesComponent implements OnInit { if (refresh) { this.pageLoaded = 0; + this.userPageLoaded = 0; } - return this.blogProvider.getEntries(this.filter, this.pageLoaded).then((result) => { + const loadPage = this.onlyMyEntries ? this.userPageLoaded : this.pageLoaded; + + return this.blogProvider.getEntries(this.filter, loadPage).then((result) => { const promises = result.entries.map((entry) => { switch (entry.publishstate) { case 'draft': @@ -131,16 +142,25 @@ export class AddonBlogEntriesComponent implements OnInit { }); if (refresh) { - this.showMyIssuesToggle = false; this.entries = result.entries; } else { - this.entries = this.entries.concat(result.entries); + this.entries = this.utils.uniqueArray(this.entries.concat(result.entries), 'id').sort((a, b) => { + return b.created - a.created; + }); } - this.canLoadMore = result.totalentries > this.entries.length; - this.pageLoaded++; - - this.showMyIssuesToggle = !this.userId; + if (this.onlyMyEntries) { + const count = this.entries.filter((entry) => { + return entry.userid == this.currentUserId; + }).length; + this.canLoadMoreUserEntries = result.totalentries > count; + this.canLoadMore = this.canLoadMoreUserEntries; + this.userPageLoaded++; + } else { + this.canLoadMoreEntries = result.totalentries > this.entries.length; + this.canLoadMore = this.canLoadMoreEntries; + this.pageLoaded++; + } return Promise.all(promises); }).catch((message) => { @@ -151,6 +171,30 @@ export class AddonBlogEntriesComponent implements OnInit { }); } + /** + * Toggle between showing only my entries or not. + * + * @param {boolean} enabled If true, filter my entries. False otherwise. + */ + onlyMyEntriesToggleChanged(enabled: boolean): void { + if (enabled) { + const count = this.entries.filter((entry) => { + return entry.userid == this.currentUserId; + }).length; + this.userPageLoaded = Math.floor(count / AddonBlogProvider.ENTRIES_PER_PAGE); + this.filter['userid'] = this.currentUserId; + + if (count == 0 && this.canLoadMoreUserEntries) { + // First time but no entry loaded. Try to load some. + this.loadMore(); + } + } else { + delete this.filter['userid']; + } + + this.canLoadMore = enabled ? this.canLoadMoreUserEntries : this.canLoadMoreEntries; + } + /** * Function to load more entries. * @@ -169,7 +213,23 @@ export class AddonBlogEntriesComponent implements OnInit { * @param {any} refresher Refresher instance. */ refresh(refresher?: any): void { - this.blogProvider.invalidateEntries(this.filter).finally(() => { + const promises = this.entries.map((entry) => { + return this.commentsProvider.invalidateCommentsData('user', entry.userid, this.component, entry.id, 'format_blog'); + }); + + promises.push(this.blogProvider.invalidateEntries(this.filter)); + + if (this.showMyEntriesToggle) { + this.filter['userid'] = this.currentUserId; + promises.push(this.blogProvider.invalidateEntries(this.filter)); + + if (!this.onlyMyEntries) { + delete this.filter['userid']; + } + + } + + this.utils.allPromises(promises).finally(() => { this.fetchEntries(true).finally(() => { if (refresher) { refresher.complete(); diff --git a/src/addon/blog/providers/course-option-handler.ts b/src/addon/blog/providers/course-option-handler.ts index 9c28973fb2f..5617d6c9258 100644 --- a/src/addon/blog/providers/course-option-handler.ts +++ b/src/addon/blog/providers/course-option-handler.ts @@ -78,10 +78,10 @@ export class AddonBlogCourseOptionHandler implements CoreCourseOptionsHandler { * Returns the data needed to render the handler. * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData(injector: Injector, courseId: number): CoreCourseOptionsHandlerData | Promise { + getDisplayData(injector: Injector, course: any): CoreCourseOptionsHandlerData | Promise { return { title: 'addon.blog.blog', class: 'addon-blog-handler', diff --git a/src/addon/blog/providers/index-link-handler.ts b/src/addon/blog/providers/index-link-handler.ts index 189fad7a34c..0670b322b53 100644 --- a/src/addon/blog/providers/index-link-handler.ts +++ b/src/addon/blog/providers/index-link-handler.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { AddonBlogProvider } from './blog'; /** @@ -27,7 +27,7 @@ export class AddonBlogIndexLinkHandler extends CoreContentLinksHandlerBase { featureName = 'CoreUserDelegate_AddonBlog:blogs'; pattern = /\/blog\/index\.php/; - constructor(private blogProvider: AddonBlogProvider, private loginHelper: CoreLoginHelperProvider) { + constructor(private blogProvider: AddonBlogProvider, private linkHelper: CoreContentLinksHelperProvider) { super(); } @@ -53,8 +53,7 @@ export class AddonBlogIndexLinkHandler extends CoreContentLinksHandlerBase { return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect('AddonBlogEntriesPage', pageParams, siteId); + this.linkHelper.goInSite(navCtrl, 'AddonBlogEntriesPage', pageParams, siteId, !Object.keys(pageParams).length); } }]; } diff --git a/src/addon/blog/providers/tag-area-handler.ts b/src/addon/blog/providers/tag-area-handler.ts new file mode 100644 index 00000000000..41413d82437 --- /dev/null +++ b/src/addon/blog/providers/tag-area-handler.ts @@ -0,0 +1,58 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreTagHelperProvider } from '@core/tag/providers/helper'; +import { CoreTagFeedComponent } from '@core/tag/components/feed/feed'; +import { AddonBlogProvider } from './blog'; + +/** + * Handler to support tags. + */ +@Injectable() +export class AddonBlogTagAreaHandler implements CoreTagAreaHandler { + name = 'AddonBlogTagAreaHandler'; + type = 'core/post'; + + constructor(private tagHelper: CoreTagHelperProvider, private blogProvider: AddonBlogProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.blogProvider.isPluginEnabled(); + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + return this.tagHelper.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreTagFeedComponent; + } +} diff --git a/src/addon/blog/providers/user-handler.ts b/src/addon/blog/providers/user-handler.ts index 039b9ed56ef..fe19563b8c1 100644 --- a/src/addon/blog/providers/user-handler.ts +++ b/src/addon/blog/providers/user-handler.ts @@ -63,7 +63,7 @@ export class AddonBlogUserHandler implements CoreUserProfileHandler { action: (event, navCtrl, user, courseId): void => { event.preventDefault(); event.stopPropagation(); - // Always use redirect to make it the new history root (to avoid "loops" in history). + this.linkHelper.goInSite(navCtrl, 'AddonBlogEntriesPage', { userId: user.id, courseId: courseId }); } }; diff --git a/src/addon/calendar/calendar.module.ts b/src/addon/calendar/calendar.module.ts index 3146e6e690f..28765fad695 100644 --- a/src/addon/calendar/calendar.module.ts +++ b/src/addon/calendar/calendar.module.ts @@ -14,18 +14,26 @@ import { NgModule } from '@angular/core'; import { AddonCalendarProvider } from './providers/calendar'; +import { AddonCalendarOfflineProvider } from './providers/calendar-offline'; import { AddonCalendarHelperProvider } from './providers/helper'; +import { AddonCalendarSyncProvider } from './providers/calendar-sync'; import { AddonCalendarMainMenuHandler } from './providers/mainmenu-handler'; +import { AddonCalendarSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonCalendarViewLinkHandler } from './providers/view-link-handler'; import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreInitDelegate } from '@providers/init'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; // List of providers (without handlers). export const ADDON_CALENDAR_PROVIDERS: any[] = [ AddonCalendarProvider, - AddonCalendarHelperProvider + AddonCalendarOfflineProvider, + AddonCalendarHelperProvider, + AddonCalendarSyncProvider ]; @NgModule({ @@ -35,15 +43,24 @@ export const ADDON_CALENDAR_PROVIDERS: any[] = [ ], providers: [ AddonCalendarProvider, + AddonCalendarOfflineProvider, AddonCalendarHelperProvider, - AddonCalendarMainMenuHandler + AddonCalendarSyncProvider, + AddonCalendarMainMenuHandler, + AddonCalendarSyncCronHandler, + AddonCalendarViewLinkHandler ] }) export class AddonCalendarModule { constructor(mainMenuDelegate: CoreMainMenuDelegate, calendarHandler: AddonCalendarMainMenuHandler, initDelegate: CoreInitDelegate, calendarProvider: AddonCalendarProvider, loginHelper: CoreLoginHelperProvider, - localNotificationsProvider: CoreLocalNotificationsProvider, updateManager: CoreUpdateManagerProvider) { + localNotificationsProvider: CoreLocalNotificationsProvider, updateManager: CoreUpdateManagerProvider, + cronDelegate: CoreCronDelegate, syncHandler: AddonCalendarSyncCronHandler, + contentLinksDelegate: CoreContentLinksDelegate, viewLinkHandler: AddonCalendarViewLinkHandler) { + mainMenuDelegate.registerHandler(calendarHandler); + cronDelegate.register(syncHandler); + contentLinksDelegate.registerHandler(viewLinkHandler); initDelegate.ready().then(() => { calendarProvider.scheduleAllSitesEventsNotifications(); @@ -58,7 +75,13 @@ export class AddonCalendarModule { return; } - loginHelper.redirect('AddonCalendarListPage', {eventId: data.eventid}, data.siteId); + // Check which page we should load. + calendarProvider.canViewMonth(data.siteId).then((canView) => { + const pageName = canView ? 'AddonCalendarIndexPage' : 'AddonCalendarListPage'; + + loginHelper.redirect(pageName, {eventId: data.eventid}, data.siteId); + }); + }); }); } diff --git a/src/addon/calendar/components/calendar/addon-calendar-calendar.html b/src/addon/calendar/components/calendar/addon-calendar-calendar.html new file mode 100644 index 00000000000..093b97064f5 --- /dev/null +++ b/src/addon/calendar/components/calendar/addon-calendar-calendar.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + +

{{ periodName }}

+
+ + + + + +
+
+ + + + + + + {{ day.shortname | translate }} + {{ day.fullname | translate }} + + + + + + + +

{{ day.mday }}

+ + +

+ + +
+ +

+ + + + {{ event.timestart * 1000 | coreFormatDate: timeFormat }} + + {{event.name}} +

+
+

{{ 'core.nummore' | translate:{$a: day.filteredEvents.length - 3} }}

+
+
+ +
+
+ +
diff --git a/src/addon/calendar/components/calendar/calendar.scss b/src/addon/calendar/components/calendar/calendar.scss new file mode 100644 index 00000000000..b04fe3d64c5 --- /dev/null +++ b/src/addon/calendar/components/calendar/calendar.scss @@ -0,0 +1,196 @@ + +$calendar-event-category-color: $purple !default; // Purple. +$calendar-event-course-color: $red !default; // Red. +$calendar-event-group-color: $yellow !default; // Yellow. +$calendar-event-user-color: $blue !default; // Blue. +$calendar-event-site-color: $green !default; // Green. +$calendar-today-bgcolor: $core-color !default; +$calendar-today-color: $white !default; +$calendar-border-color: $gray !default; + +ion-app.app-root page-addon-calendar-list, +ion-app.app-root page-addon-calendar-day, +ion-app.app-root addon-calendar-upcoming-events { + + .item.addon-calendar-event { + > .icon { + color: white; + border-radius: 50%; + width: 36px; + height: 36px; + line-height: 36px; + + &.fa { + font-size: 20px; + &::before { + width: 1.9em; + } + } + } + > .core-module-icon { + margin: 9px 8px 9px 8px; + } + + &.addon-calendar-eventtype-category > .icon { + background-color: $calendar-event-category-color; + } + &.addon-calendar-eventtype-course > .icon { + background-color: $calendar-event-course-color; + } + &.addon-calendar-eventtype-group > .icon { + background-color: $calendar-event-group-color; + } + &.addon-calendar-eventtype-user > .icon { + background-color: $calendar-event-user-color; + } + &.addon-calendar-eventtype-site > .icon { + background-color: $calendar-event-site-color; + } + } +} + +ion-app.app-root addon-calendar-calendar { + + .addon-calendar-navigation { + @include padding(5px, 10px, null, 10px); + } + + .addon-calendar-months { + background-color: white; + padding: 0; + } + + .addon-calendar-day { + border-bottom: 1px solid $calendar-border-color; + @include border-end(1px, solid, $calendar-border-color); + overflow: hidden; + min-height: 60px; + + &:first-child { + @include padding(null, null, null, 10px); + } + &:last-child { + @include border-end(0, null, null); + @include padding(null, 8px, null, null); + } + + &.addon-calendar-event-past-day > .addon-calendar-dot-types, + &.addon-calendar-event-past-day > .addon-calendar-day-events { + opacity: 0.5; + } + + .addon-calendar-day-number { + margin: 0; + + span { + line-height: 24px; + font-weight: 500; + display: inline-block; + margin: 3px; + width: max-content; + width: 24px; + height: 24px; + text-align: center; + } + } + + @include media-breakpoint-up(md) { + .addon-calendar-day-number { + text-align: left; + } + } + + &.today .addon-calendar-day-number span { + background-color: $calendar-today-bgcolor; + color: $calendar-today-color; + + border-radius: 50%; + } + &.dayblank { + background-color: $gray-lighter; + } + + .addon-calendar-event { + margin-top: 0.6em; + margin-bottom: 0.6em; + overflow: hidden; + white-space: nowrap; + + &.addon-calendar-event-past { + opacity: 0.5; + } + + .addon-calendar-event-name { + font-weight: 500; + } + } + + .addon-calendar-day-more { + @include margin(0.6em, null, 0.6em, 4px); + } + + .addon-calendar-dot-types { + margin: 0; + } + } + + .addon-calendar-period { + flex-grow: 3; + h3 { + margin-top: 10px; + font-size: 1.6rem; + } + } + + .addon-calendar-weekday { + border-bottom: 1px solid $list-md-border-color; + } + + .addon-calendar-day-events { + @include text-align('start'); + + ion-icon { + @include margin-horizontal(null, 2px); + font-size: 1em; + } + } + + .addon-calendar-event, .addon-calendar-day-number, .addon-calendar-day-more { + cursor: pointer; + } + + .calendar_event_type { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + border: 1px solid white; + @include margin-horizontal(1px, 1px); + + + + &.calendar_event_category { + background-color: $calendar-event-category-color; + } + &.calendar_event_course { + background-color: $calendar-event-course-color; + } + &.calendar_event_group { + background-color: $calendar-event-group-color; + } + &.calendar_event_user { + background-color: $calendar-event-user-color; + } + &.calendar_event_site { + background-color: $calendar-event-site-color; + } + } + + .core-module-icon { + @include margin-horizontal(1px, 1px); + width: 16px; + height: 16px; + display: inline-block; + vertical-align: bottom; + } +} \ No newline at end of file diff --git a/src/addon/calendar/components/calendar/calendar.ts b/src/addon/calendar/components/calendar/calendar.ts new file mode 100644 index 00000000000..269310608ef --- /dev/null +++ b/src/addon/calendar/components/calendar/calendar.ts @@ -0,0 +1,515 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnDestroy, OnInit, Input, OnChanges, SimpleChange, Output, EventEmitter } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreAppProvider } from '@providers/app'; + +/** + * Component that displays a calendar. + */ +@Component({ + selector: 'addon-calendar-calendar', + templateUrl: 'addon-calendar-calendar.html', +}) +export class AddonCalendarCalendarComponent implements OnInit, OnChanges, OnDestroy { + @Input() initialYear: number | string; // Initial year to load. + @Input() initialMonth: number | string; // Initial month to load. + @Input() courseId: number | string; + @Input() categoryId: number | string; // Category ID the course belongs to. + @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(); + @Output() onDayClicked = new EventEmitter<{day: number, month: number, year: number}>(); + + periodName: string; + weekDays: any[]; + weeks: any[]; + loaded = false; + timeFormat: string; + isCurrentMonth: boolean; + isPastMonth: boolean; + + protected year: number; + protected month: number; + protected categoriesRetrieved = false; + protected categories = {}; + protected currentSiteId: string; + protected offlineEvents: {[monthId: string]: {[day: number]: any[]}} = {}; // Offline events classified in month & day. + protected offlineEditedEventsIds = []; // IDs of events edited in offline. + protected deletedEvents = []; // Events deleted in offline. + protected currentTime: number; + + // Observers. + protected undeleteEventObserver: any; + protected obsDefaultTimeChange: any; + + constructor(eventsProvider: CoreEventsProvider, + sitesProvider: CoreSitesProvider, + localNotificationsProvider: CoreLocalNotificationsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarHelper: AddonCalendarHelperProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private domUtils: CoreDomUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, + private coursesProvider: CoreCoursesProvider, + private appProvider: CoreAppProvider) { + + this.currentSiteId = sitesProvider.getCurrentSiteId(); + + if (localNotificationsProvider.isAvailable()) { + // Re-schedule events if default time changes. + this.obsDefaultTimeChange = eventsProvider.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + this.weeks.forEach((week) => { + week.days.forEach((day) => { + calendarProvider.scheduleEventsNotifications(day.events); + }); + }); + }, this.currentSiteId); + } + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = eventsProvider.on(AddonCalendarProvider.UNDELETED_EVENT_EVENT, (data) => { + if (data && data.eventId) { + // Mark it as undeleted, no need to refresh. + this.undeleteEvent(data.eventId); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + } + }, this.currentSiteId); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + const now = new Date(); + + this.year = this.initialYear ? Number(this.initialYear) : now.getFullYear(); + this.month = this.initialMonth ? Number(this.initialMonth) : now.getMonth() + 1; + + this.calculateIsCurrentMonth(); + + this.fetchData(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + this.canNavigate = typeof this.canNavigate == 'undefined' ? true : this.utils.isTrueOrOne(this.canNavigate); + this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : + this.utils.isTrueOrOne(this.displayNavButtons); + + if ((changes.courseId || changes.categoryId) && this.weeks) { + this.filterEvents(); + } + } + + /** + * Fetch contacts. + * + * @param {boolean} [refresh=false] True if we are refreshing events. + * @return {Promise} Promise resolved when done. + */ + fetchData(refresh: boolean = false): Promise { + const promises = []; + + promises.push(this.loadCategories()); + + // Get offline events. + promises.push(this.calendarOffline.getAllEditedEvents().then((events) => { + // Format data. + events.forEach((event) => { + event.offline = true; + this.calendarHelper.formatEventData(event); + }); + + // Classify them by month. + this.offlineEvents = this.calendarHelper.classifyIntoMonths(events); + + // Get the IDs of events edited in offline. + const filtered = events.filter((event) => { + return event.id > 0; + }); + this.offlineEditedEventsIds = filtered.map((event) => { + return event.id; + }); + })); + + // Get events deleted in offline. + promises.push(this.calendarOffline.getAllDeletedEventsIds().then((ids) => { + this.deletedEvents = ids; + })); + + // Get time format to use. + promises.push(this.calendarProvider.getCalendarTimeFormat().then((value) => { + this.timeFormat = value; + })); + + return Promise.all(promises).then(() => { + return this.fetchEvents(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch the events for current month. + * + * @return {Promise} Promise resolved when done. + */ + fetchEvents(): Promise { + // Don't pass courseId and categoryId, we'll filter them locally. + return this.calendarProvider.getMonthlyEvents(this.year, this.month).catch((error) => { + if (!this.appProvider.isOnline()) { + // Allow navigating to non-cached months in offline (behave as if using emergency cache). + return this.calendarHelper.getOfflineMonthWeeks(this.year, this.month); + } else { + return Promise.reject(error); + } + }).then((result) => { + // Calculate the period name. We don't use the one in result because it's in server's language. + this.periodName = this.timeUtils.userDate(new Date(this.year, this.month - 1).getTime(), 'core.strftimemonthyear'); + + this.weekDays = this.calendarProvider.getWeekDays(result.daynames[0].dayno); + this.weeks = result.weeks; + + this.calculateIsCurrentMonth(); + + if (this.isCurrentMonth) { + const currentDay = new Date().getDate(); + let isPast = true; + + this.weeks.forEach((week) => { + week.days.some((day) => { + day.istoday = day.mday == currentDay; + day.ispast = isPast && !day.istoday; + isPast = day.ispast; + + if (day.istoday) { + day.events.forEach((event) => { + event.ispast = this.isEventPast(event); + }); + + return true; + } + + return day.istoday; + }); + }); + } + + // 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} Promise resolved when done. + */ + protected loadCategories(): Promise { + if (this.categoriesRetrieved) { + // Already retrieved, stop. + return Promise.resolve(); + } + + return this.coursesProvider.getCategories(0, true).then((cats) => { + this.categoriesRetrieved = true; + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + }).catch(() => { + // Ignore errors. + }); + } + + /** + * Filter events to only display events belonging to a certain course. + */ + filterEvents(): void { + const courseId = this.courseId ? Number(this.courseId) : undefined, + categoryId = this.categoryId ? Number(this.categoryId) : undefined; + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + if (!courseId || courseId < 0) { + day.filteredEvents = day.events; + } else { + day.filteredEvents = day.events.filter((event) => { + return this.calendarHelper.shouldDisplayEvent(event, courseId, categoryId, this.categories); + }); + } + + // Re-calculate some properties. + this.calendarHelper.calculateDayData(day, day.filteredEvents); + }); + }); + } + + /** + * Refresh events. + * + * @param {boolean} [afterChange] Whether the refresh is done after an event has changed or has been synced. + * @return {Promise} Promise resolved when done. + */ + refreshData(afterChange?: boolean): Promise { + const promises = []; + + // Don't invalidate monthly events after a change, it has already been handled. + if (!afterChange) { + promises.push(this.calendarProvider.invalidateMonthlyEvents(this.year, this.month)); + } + promises.push(this.coursesProvider.invalidateCategories(0, true)); + promises.push(this.calendarProvider.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + return Promise.all(promises).then(() => { + return this.fetchData(true); + }); + } + + /** + * Load next month. + */ + loadNext(): void { + this.increaseMonth(); + + this.loaded = false; + + this.fetchEvents().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.decreaseMonth(); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Load previous month. + */ + loadPrevious(): void { + this.decreaseMonth(); + + this.loaded = false; + + this.fetchEvents().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.increaseMonth(); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * An event was clicked. + * + * @param {any} calendarEvent Calendar event.. + * @param {MouseEvent} event Mouse event. + */ + eventClicked(calendarEvent: any, event: MouseEvent): void { + this.onEventClicked.emit(calendarEvent.id); + event.stopPropagation(); + } + + /** + * A day was clicked. + * + * @param {number} day Day. + */ + dayClicked(day: number): void { + this.onDayClicked.emit({day: day, month: this.month, year: this.year}); + } + + /** + * Check if user is viewing the current month. + */ + calculateIsCurrentMonth(): void { + const now = new Date(); + + this.currentTime = this.timeUtils.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); + } + + /** + * Go to current month. + */ + goToCurrentMonth(): void { + const now = new Date(), + initialMonth = this.month, + initialYear = this.year; + + this.month = now.getMonth() + 1; + this.year = now.getFullYear(); + + this.loaded = false; + + this.fetchEvents().then(() => { + this.isCurrentMonth = true; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.year = initialYear; + this.month = initialMonth; + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Decrease the current month. + */ + protected decreaseMonth(): void { + if (this.month === 1) { + this.month = 12; + this.year--; + } else { + this.month--; + } + } + + /** + * Increase the current month. + */ + protected increaseMonth(): void { + if (this.month === 12) { + this.month = 1; + this.year++; + } else { + this.month++; + } + } + + /** + * Merge online events with the offline events of that period. + */ + protected mergeEvents(): void { + const monthOfflineEvents = this.offlineEvents[this.calendarHelper.getMonthId(this.year, this.month)]; + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + + // Format online events. + day.events.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); + + // Schedule notifications for the events retrieved (only future events will be scheduled). + this.calendarProvider.scheduleEventsNotifications(day.events); + + 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.events.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) => { + return this.offlineEditedEventsIds.indexOf(event.id) == -1; + }); + } + + if (monthOfflineEvents && monthOfflineEvents[day.mday]) { + // Add the offline events (either new or edited). + day.events = this.sortEvents(day.events.concat(monthOfflineEvents[day.mday])); + } + } + }); + }); + } + + /** + * Sort events by timestart. + * + * @param {any[]} events List to sort. + */ + protected sortEvents(events: any[]): any[] { + return events.sort((a, b) => { + if (a.timestart == b.timestart) { + return a.timeduration - b.timeduration; + } + + return a.timestart - b.timestart; + }); + } + + /** + * Undelete a certain event. + * + * @param {number} eventId Event ID. + */ + protected undeleteEvent(eventId: number): void { + if (!this.weeks) { + return; + } + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + const event = day.events.find((event) => { + return event.id == eventId; + }); + + if (event) { + event.deleted = false; + } + }); + }); + } + + /** + * Returns if the event is in the past or not. + * @param {any} event Event object. + * @return {boolean} True if it's in the past. + */ + isEventPast(event: any): boolean { + return (event.timestart + event.timeduration) < this.currentTime; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.undeleteEventObserver && this.undeleteEventObserver.off(); + this.obsDefaultTimeChange && this.obsDefaultTimeChange.off(); + } +} diff --git a/src/addon/calendar/components/components.module.ts b/src/addon/calendar/components/components.module.ts new file mode 100644 index 00000000000..3928f6dd914 --- /dev/null +++ b/src/addon/calendar/components/components.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonCalendarCalendarComponent } from '../components/calendar/calendar'; +import { AddonCalendarUpcomingEventsComponent } from '../components/upcoming-events/upcoming-events'; + +@NgModule({ + declarations: [ + AddonCalendarCalendarComponent, + AddonCalendarUpcomingEventsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule + ], + providers: [ + ], + exports: [ + AddonCalendarCalendarComponent, + AddonCalendarUpcomingEventsComponent + ] +}) +export class AddonCalendarComponentsModule {} diff --git a/src/addon/calendar/components/upcoming-events/addon-calendar-upcoming-events.html b/src/addon/calendar/components/upcoming-events/addon-calendar-upcoming-events.html new file mode 100644 index 00000000000..68f1746083b --- /dev/null +++ b/src/addon/calendar/components/upcoming-events/addon-calendar-upcoming-events.html @@ -0,0 +1,24 @@ + + + + + + + + + +

+

+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} + +
+
+
+ +
diff --git a/src/addon/calendar/components/upcoming-events/upcoming-events.ts b/src/addon/calendar/components/upcoming-events/upcoming-events.ts new file mode 100644 index 00000000000..d56a3470bd4 --- /dev/null +++ b/src/addon/calendar/components/upcoming-events/upcoming-events.ts @@ -0,0 +1,339 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnDestroy, OnInit, Input, OnChanges, SimpleChange, Output, EventEmitter } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreConstants } from '@core/constants'; + +/** + * Component that displays upcoming events. + */ +@Component({ + selector: 'addon-calendar-upcoming-events', + templateUrl: 'addon-calendar-upcoming-events.html', +}) +export class AddonCalendarUpcomingEventsComponent implements OnInit, OnChanges, OnDestroy { + @Input() courseId: number | string; + @Input() categoryId: number | string; // Category ID the course belongs to. + @Output() onEventClicked = new EventEmitter(); + + filteredEvents = []; + loaded = false; + + protected year: number; + protected month: number; + protected categoriesRetrieved = false; + protected categories = {}; + protected currentSiteId: string; + protected events = []; // Events (both online and offline). + protected onlineEvents = []; + protected offlineEvents = []; // Offline events. + protected deletedEvents = []; // Events deleted in offline. + protected lookAhead: number; + protected timeFormat: string; + + // Observers. + protected undeleteEventObserver: any; + protected obsDefaultTimeChange: any; + + constructor(eventsProvider: CoreEventsProvider, + sitesProvider: CoreSitesProvider, + localNotificationsProvider: CoreLocalNotificationsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarHelper: AddonCalendarHelperProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private domUtils: CoreDomUtilsProvider, + private coursesProvider: CoreCoursesProvider) { + + this.currentSiteId = sitesProvider.getCurrentSiteId(); + + if (localNotificationsProvider.isAvailable()) { + // Re-schedule events if default time changes. + this.obsDefaultTimeChange = eventsProvider.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + calendarProvider.scheduleEventsNotifications(this.onlineEvents); + }, this.currentSiteId); + } + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = eventsProvider.on(AddonCalendarProvider.UNDELETED_EVENT_EVENT, (data) => { + if (data && data.eventId) { + // Mark it as undeleted, no need to refresh. + this.undeleteEvent(data.eventId); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + } + }, this.currentSiteId); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.fetchData(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + if (changes.courseId || changes.categoryId) { + this.filterEvents(); + } + } + + /** + * Fetch data. + * + * @param {boolean} [refresh=false] True if we are refreshing events. + * @return {Promise} Promise resolved when done. + */ + fetchData(refresh: boolean = false): Promise { + const promises = []; + + promises.push(this.loadCategories()); + + // Get offline events. + promises.push(this.calendarOffline.getAllEditedEvents().then((events) => { + // Format data. + events.forEach((event) => { + event.offline = true; + this.calendarHelper.formatEventData(event); + }); + + this.offlineEvents = this.sortEvents(events); + })); + + // Get events deleted in offline. + promises.push(this.calendarOffline.getAllDeletedEventsIds().then((ids) => { + this.deletedEvents = ids; + })); + + // Get user preferences. + promises.push(this.calendarProvider.getCalendarLookAhead().then((value) => { + this.lookAhead = value; + })); + + promises.push(this.calendarProvider.getCalendarTimeFormat().then((value) => { + this.timeFormat = value; + })); + + return Promise.all(promises).then(() => { + return this.fetchEvents(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch upcoming events. + * + * @return {Promise} Promise resolved when done. + */ + fetchEvents(): Promise { + // Don't pass courseId and categoryId, we'll filter them locally. + return this.calendarProvider.getUpcomingEvents().then((result) => { + const promises = []; + + this.onlineEvents = result.events; + + this.onlineEvents.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); + + // Schedule notifications for the events retrieved. + this.calendarProvider.scheduleEventsNotifications(this.onlineEvents); + + // Merge the online events with offline data. + this.events = this.mergeEvents(); + + // Filter events by course. + this.filterEvents(); + + // Re-calculate the formatted time so it uses the device date. + this.events.forEach((event) => { + promises.push(this.calendarProvider.formatEventTime(event, this.timeFormat).then((time) => { + event.formattedtime = time; + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Load categories to be able to filter events. + * + * @return {Promise} Promise resolved when done. + */ + protected loadCategories(): Promise { + if (this.categoriesRetrieved) { + // Already retrieved, stop. + return Promise.resolve(); + } + + return this.coursesProvider.getCategories(0, true).then((cats) => { + this.categoriesRetrieved = true; + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + }).catch(() => { + // Ignore errors. + }); + } + + /** + * Filter events to only display events belonging to a certain course. + */ + filterEvents(): void { + const courseId = this.courseId ? Number(this.courseId) : undefined, + categoryId = this.categoryId ? Number(this.categoryId) : undefined; + + if (!courseId || courseId < 0) { + this.filteredEvents = this.events; + } else { + this.filteredEvents = this.events.filter((event) => { + return this.calendarHelper.shouldDisplayEvent(event, courseId, categoryId, this.categories); + }); + } + } + + /** + * Refresh events. + * + * @param {boolean} [afterChange] Whether the refresh is done after an event has changed or has been synced. + * @return {Promise} Promise resolved when done. + */ + refreshData(afterChange?: boolean): Promise { + const promises = []; + + // Don't invalidate upcoming events after a change, it has already been handled. + if (!afterChange) { + promises.push(this.calendarProvider.invalidateAllUpcomingEvents()); + } + promises.push(this.coursesProvider.invalidateCategories(0, true)); + promises.push(this.calendarProvider.invalidateLookAhead()); + promises.push(this.calendarProvider.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + return Promise.all(promises).then(() => { + return this.fetchData(true); + }); + } + + /** + * An event was clicked. + * + * @param {any} event Event. + */ + eventClicked(event: any): void { + this.onEventClicked.emit(event.id); + } + + /** + * Merge online events with the offline events of that period. + * + * @return {any[]} Merged events. + */ + protected mergeEvents(): any[] { + if (!this.offlineEvents.length && !this.deletedEvents.length) { + // No offline events, nothing to merge. + return this.onlineEvents; + } + + const start = Date.now() / 1000, + end = start + (CoreConstants.SECONDS_DAY * this.lookAhead); + let result = this.onlineEvents; + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + result.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + }); + } + + if (this.offlineEvents.length) { + // Remove the online events that were modified in offline. + result = result.filter((event) => { + const offlineEvent = this.offlineEvents.find((ev) => { + return ev.id == event.id; + }); + + return !offlineEvent; + }); + } + + // Now get the offline events that belong to this period. + const periodOfflineEvents = this.offlineEvents.filter((event) => { + return (event.timestart >= start || event.timestart + event.timeduration >= start) && event.timestart <= end; + }); + + // Merge both arrays and sort them. + result = result.concat(periodOfflineEvents); + + return this.sortEvents(result); + } + + /** + * Sort events by timestart. + * + * @param {any[]} events List to sort. + */ + protected sortEvents(events: any[]): any[] { + return events.sort((a, b) => { + if (a.timestart == b.timestart) { + return a.timeduration - b.timeduration; + } + + return a.timestart - b.timestart; + }); + } + + /** + * Undelete a certain event. + * + * @param {number} eventId Event ID. + */ + protected undeleteEvent(eventId: number): void { + const event = this.onlineEvents.find((event) => { + return event.id == eventId; + }); + + if (event) { + event.deleted = false; + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.undeleteEventObserver && this.undeleteEventObserver.off(); + this.obsDefaultTimeChange && this.obsDefaultTimeChange.off(); + } +} diff --git a/src/addon/calendar/lang/en.json b/src/addon/calendar/lang/en.json index 6ccb04caaf5..ea79d56e51c 100644 --- a/src/addon/calendar/lang/en.json +++ b/src/addon/calendar/lang/en.json @@ -1,16 +1,59 @@ { + "allday": "All day", "calendar": "Calendar", + "calendarevent": "Calendar event", "calendarevents": "Calendar events", "calendarreminders": "Calendar reminders", + "confirmeventdelete": "Are you sure you want to delete the \"{{$a}}\" event?", + "confirmeventseriesdelete": "The \"{{$a.name}}\" event is part of a series. Do you want to delete just this event, or all {{$a.count}} events in the series?", + "currentmonth": "Current Month", + "daynext": "Next day", + "dayprev": "Previous day", "defaultnotificationtime": "Default notification time", + "deleteallevents": "Delete all events", + "deleteevent": "Delete event", + "deleteoneevent": "Delete this event", + "durationminutes": "Duration in minutes", + "durationnone": "Without duration", + "durationuntil": "Until", + "editevent": "Editing event", "errorloadevent": "Error loading event.", "errorloadevents": "Error loading events.", + "eventcalendareventdeleted": "Calendar event deleted", + "eventduration": "Duration", "eventendtime": "End time", + "eventkind": "Type of event", + "eventname": "Event title", "eventstarttime": "Start time", + "eventtype": "Event type", + "fri": "Fri", + "friday": "Friday", "gotoactivity": "Go to activity", + "invalidtimedurationminutes": "The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.", + "invalidtimedurationuntil": "The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.", + "mon": "Mon", + "monday": "Monday", + "monthlyview": "Monthly view", + "newevent": "New event", "noevents": "There are no events", + "nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event", "reminders": "Reminders", + "repeatedevents": "Repeated events", + "repeateditall": "Also apply changes to the other {{$a}} events in this repeat series", + "repeateditthis": "Apply changes to this event only", + "repeatevent": "Repeat this event", + "repeatweeksl": "Repeat weekly, creating altogether", + "sat": "Sat", + "saturday": "Saturday", "setnewreminder": "Set a new reminder", + "sun": "Sun", + "sunday": "Sunday", + "thu": "Thu", + "thursday": "Thursday", + "today": "Today", + "tomorrow": "Tomorrow", + "tue": "Tue", + "tuesday": "Tuesday", "typeclose": "Close event", "typecourse": "Course event", "typecategory": "Category event", @@ -19,5 +62,10 @@ "typegroup": "Group event", "typeopen": "Open event", "typesite": "Site event", - "typeuser": "User event" + "typeuser": "User event", + "upcomingevents": "Upcoming events", + "wed": "Wed", + "wednesday": "Wednesday", + "when": "When", + "yesterday": "Yesterday" } \ No newline at end of file diff --git a/src/addon/calendar/pages/day/day.html b/src/addon/calendar/pages/day/day.html new file mode 100644 index 00000000000..34335a7dee5 --- /dev/null +++ b/src/addon/calendar/pages/day/day.html @@ -0,0 +1,75 @@ + + + {{ 'addon.calendar.calendarevents' | translate }} + + + + + + + + + + + + + + + + + + + + + + + +

{{ periodName }}

+
+ + + + + +
+
+ + + + + {{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }} + + + + + + + + + + +

+

+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} + +
+
+
+ + + + + +
+
diff --git a/src/addon/calendar/pages/day/day.module.ts b/src/addon/calendar/pages/day/day.module.ts new file mode 100644 index 00000000000..c4e33dbb9df --- /dev/null +++ b/src/addon/calendar/pages/day/day.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonCalendarDayPage } from './day'; + +@NgModule({ + declarations: [ + AddonCalendarDayPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(AddonCalendarDayPage), + TranslateModule.forChild() + ], +}) +export class AddonCalendarDayPageModule {} diff --git a/src/addon/calendar/pages/day/day.scss b/src/addon/calendar/pages/day/day.scss new file mode 100644 index 00000000000..4918c1312ec --- /dev/null +++ b/src/addon/calendar/pages/day/day.scss @@ -0,0 +1,9 @@ +page-addon-calendar-day { + .addon-calendar-period { + flex-grow: 3; + h3 { + margin-top: 10px; + font-size: 1.6rem; + } + } +} \ No newline at end of file diff --git a/src/addon/calendar/pages/day/day.ts b/src/addon/calendar/pages/day/day.ts new file mode 100644 index 00000000000..c3b566f889f --- /dev/null +++ b/src/addon/calendar/pages/day/day.ts @@ -0,0 +1,702 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OFx ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, NgZone } from '@angular/core'; +import { IonicPage, NavParams, NavController } from 'ionic-angular'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreCoursesHelperProvider } from '@core/courses/providers/helper'; +import { Network } from '@ionic-native/network'; +import * as moment from 'moment'; + +/** + * Page that displays the calendar events for a certain day. + */ +@IonicPage({ segment: 'addon-calendar-day' }) +@Component({ + selector: 'page-addon-calendar-day', + templateUrl: 'day.html', +}) +export class AddonCalendarDayPage implements OnInit, OnDestroy { + + protected currentSiteId: string; + protected year: number; + protected month: number; + protected day: number; + protected categories = {}; + protected events = []; // Events (both online and offline). + protected onlineEvents = []; + protected offlineEvents = {}; // Offline events. + protected offlineEditedEventsIds = []; // IDs of events edited in offline. + protected deletedEvents = []; // Events deleted in offline. + protected timeFormat: string; + protected currentMoment: moment.Moment; + protected currentTime: number; + + // Observers. + protected newEventObserver: any; + protected discardedObserver: any; + protected editEventObserver: any; + protected deleteEventObserver: any; + protected undeleteEventObserver: any; + protected syncObserver: any; + protected manualSyncObserver: any; + protected onlineObserver: any; + protected obsDefaultTimeChange: any; + + periodName: string; + filteredEvents = []; + courseId: number; + categoryId: number; + canCreate = false; + courses: any[]; + loaded = false; + hasOffline = false; + isOnline = false; + syncIcon: string; + isCurrentDay: boolean; + isPastDay: boolean; + + constructor(localNotificationsProvider: CoreLocalNotificationsProvider, + navParams: NavParams, + network: Network, + zone: NgZone, + sitesProvider: CoreSitesProvider, + private navCtrl: NavController, + private domUtils: CoreDomUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private calendarHelper: AddonCalendarHelperProvider, + private calendarSync: AddonCalendarSyncProvider, + private eventsProvider: CoreEventsProvider, + private coursesProvider: CoreCoursesProvider, + private coursesHelper: CoreCoursesHelperProvider, + private appProvider: CoreAppProvider) { + + const now = new Date(); + + this.year = navParams.get('year') || now.getFullYear(); + this.month = navParams.get('month') || (now.getMonth() + 1); + this.day = navParams.get('day') || now.getDate(); + this.courseId = navParams.get('courseId'); + this.currentSiteId = sitesProvider.getCurrentSiteId(); + + if (localNotificationsProvider.isAvailable()) { + // Re-schedule events if default time changes. + this.obsDefaultTimeChange = eventsProvider.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + calendarProvider.scheduleEventsNotifications(this.onlineEvents); + }, this.currentSiteId); + } + + // Listen for events added. When an event is added, reload the data. + this.newEventObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => { + if (data && data.event) { + this.loaded = false; + this.refreshData(true, false, true); + } + }, this.currentSiteId); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + this.loaded = false; + this.refreshData(true, false, true); + }, this.currentSiteId); + + // Listen for events edited. When an event is edited, reload the data. + this.editEventObserver = eventsProvider.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data) => { + if (data && data.event) { + this.loaded = false; + this.refreshData(true, false, true); + } + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = eventsProvider.on(AddonCalendarSyncProvider.AUTO_SYNCED, (data) => { + this.loaded = false; + this.refreshData(false, false, true); + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = eventsProvider.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => { + if (data && (data.source != 'day' || data.year != this.year || data.month != this.month || data.day != this.day)) { + this.loaded = false; + this.refreshData(false, false, true); + } + }, this.currentSiteId); + + // Update the events when an event is deleted. + this.deleteEventObserver = eventsProvider.on(AddonCalendarProvider.DELETED_EVENT_EVENT, (data) => { + if (data && !data.sent) { + // Event was deleted in offline. Just mark it as deleted, no need to refresh. + this.hasOffline = this.markAsDeleted(data.eventId, true) || this.hasOffline; + this.deletedEvents.push(data.eventId); + } else { + this.loaded = false; + this.refreshData(false, false, true); + } + }, this.currentSiteId); + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = eventsProvider.on(AddonCalendarProvider.UNDELETED_EVENT_EVENT, (data) => { + if (data && data.eventId) { + // Mark it as undeleted, no need to refresh. + const found = this.markAsDeleted(data.eventId, false); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + + if (found) { + // The deleted event belongs to current list. Re-calculate "hasOffline". + this.hasOffline = false; + + if (this.events.length != this.onlineEvents.length) { + this.hasOffline = true; + } else { + const event = this.events.find((event) => { + return event.deleted || event.offline; + }); + + this.hasOffline = !!event; + } + } + } + }, this.currentSiteId); + + // Refresh online status when changes. + this.onlineObserver = network.onchange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this.isOnline = this.appProvider.isOnline(); + }); + }); + } + + /** + * View loaded. + */ + ngOnInit(): void { + this.calculateCurrentMoment(); + this.calculateIsCurrentDay(); + + this.fetchData(true, false); + } + + /** + * Fetch all the data required for the view. + * + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + fetchData(sync?: boolean, showErrors?: boolean): Promise { + + this.syncIcon = 'spinner'; + this.isOnline = this.appProvider.isOnline(); + + const promise = sync ? this.sync() : Promise.resolve(); + + return promise.then(() => { + const promises = []; + + // Load courses for the popover. + promises.push(this.coursesHelper.getCoursesForPopover(this.courseId).then((data) => { + this.courses = data.courses; + this.categoryId = data.categoryId; + })); + + // Get categories. + promises.push(this.loadCategories()); + + // Get offline events. + promises.push(this.calendarOffline.getAllEditedEvents().then((events) => { + // Format data. + events.forEach((event) => { + event.offline = true; + this.calendarHelper.formatEventData(event); + }); + + // Classify them by month & day. + this.offlineEvents = this.calendarHelper.classifyIntoMonths(events); + + // // Get the IDs of events edited in offline. + const filtered = events.filter((event) => { + return event.id > 0; + }); + this.offlineEditedEventsIds = filtered.map((event) => { + return event.id; + }); + })); + + // Get events deleted in offline. + promises.push(this.calendarOffline.getAllDeletedEventsIds().then((ids) => { + this.deletedEvents = ids; + })); + + // Check if user can create events. + promises.push(this.calendarHelper.canEditEvents(this.courseId).then((canEdit) => { + this.canCreate = canEdit; + })); + + // Get user preferences. + promises.push(this.calendarProvider.getCalendarTimeFormat().then((value) => { + this.timeFormat = value; + })); + + return Promise.all(promises); + }).then(() => { + return this.fetchEvents(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + }).finally(() => { + this.loaded = true; + this.syncIcon = 'sync'; + }); + } + + /** + * Fetch the events for current day. + * + * @return {Promise} Promise resolved when done. + */ + fetchEvents(): Promise { + // Don't pass courseId and categoryId, we'll filter them locally. + return this.calendarProvider.getDayEvents(this.year, this.month, this.day).catch((error) => { + if (!this.appProvider.isOnline()) { + // Allow navigating to non-cached days in offline (behave as if using emergency cache). + return Promise.resolve({ events: [] }); + } else { + return Promise.reject(error); + } + }).then((result) => { + const promises = []; + + // Calculate the period name. We don't use the one in result because it's in server's language. + this.periodName = this.timeUtils.userDate(new Date(this.year, this.month - 1, this.day).getTime(), + 'core.strftimedaydate'); + + this.onlineEvents = result.events; + this.onlineEvents.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); + + // Schedule notifications for the events retrieved (only future events will be scheduled). + this.calendarProvider.scheduleEventsNotifications(this.onlineEvents); + + // Merge the online events with offline data. + this.events = this.mergeEvents(); + + // Filter events by course. + this.filterEvents(); + + this.calculateIsCurrentDay(); + + // Re-calculate the formatted time so it uses the device date. + const dayTime = this.currentMoment.unix() * 1000; + this.events.forEach((event) => { + event.ispast = this.isPastDay || (this.isCurrentDay && this.isEventPast(event)); + promises.push(this.calendarProvider.formatEventTime(event, this.timeFormat, true, dayTime).then((time) => { + event.formattedtime = time; + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Merge online events with the offline events of that period. + * + * @return {any[]} Merged events. + */ + protected mergeEvents(): any[] { + this.hasOffline = false; + + if (!Object.keys(this.offlineEvents).length && !this.deletedEvents.length) { + // No offline events, nothing to merge. + return this.onlineEvents; + } + + const monthOfflineEvents = this.offlineEvents[this.calendarHelper.getMonthId(this.year, this.month)], + dayOfflineEvents = monthOfflineEvents && monthOfflineEvents[this.day]; + let result = this.onlineEvents; + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + result.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + + if (event.deleted) { + this.hasOffline = true; + } + }); + } + + if (this.offlineEditedEventsIds.length) { + // Remove the online events that were modified in offline. + result = result.filter((event) => { + return this.offlineEditedEventsIds.indexOf(event.id) == -1; + }); + + if (result.length != this.onlineEvents.length) { + this.hasOffline = true; + } + } + + if (dayOfflineEvents && dayOfflineEvents.length) { + // Add the offline events (either new or edited). + this.hasOffline = true; + result = this.sortEvents(result.concat(dayOfflineEvents)); + } + + return result; + } + + /** + * Filter events to only display events belonging to a certain course. + */ + protected filterEvents(): void { + if (!this.courseId || this.courseId < 0) { + this.filteredEvents = this.events; + } else { + this.filteredEvents = this.events.filter((event) => { + return this.calendarHelper.shouldDisplayEvent(event, this.courseId, this.categoryId, this.categories); + }); + } + } + + /** + * Sort events by timestart. + * + * @param {any[]} events List to sort. + */ + protected sortEvents(events: any[]): any[] { + return events.sort((a, b) => { + if (a.timestart == b.timestart) { + return a.timeduration - b.timeduration; + } + + return a.timestart - b.timestart; + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void, showErrors?: boolean): Promise { + if (this.loaded) { + return this.refreshData(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); + } + + return Promise.resolve(); + } + + /** + * Refresh the data. + * + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @param {boolean} [afterChange] Whether the refresh is done after an event has changed or has been synced. + * @return {Promise} Promise resolved when done. + */ + refreshData(sync?: boolean, showErrors?: boolean, afterChange?: boolean): Promise { + this.syncIcon = 'spinner'; + + const promises = []; + + // Don't invalidate day events after a change, it has already been handled. + if (!afterChange) { + promises.push(this.calendarProvider.invalidateDayEvents(this.year, this.month, this.day)); + } + promises.push(this.calendarProvider.invalidateAllowedEventTypes()); + promises.push(this.coursesProvider.invalidateCategories(0, true)); + promises.push(this.calendarProvider.invalidateTimeFormat()); + + return Promise.all(promises).finally(() => { + return this.fetchData(sync, showErrors); + }); + } + + /** + * Load categories to be able to filter events. + * + * @return {Promise} Promise resolved when done. + */ + protected loadCategories(): Promise { + return this.coursesProvider.getCategories(0, true).then((cats) => { + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + }).catch(() => { + // Ignore errors. + }); + } + + /** + * Try to synchronize offline events. + * + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + protected sync(showErrors?: boolean): Promise { + return this.calendarSync.syncEvents().then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'day'; + result.day = this.day; + result.month = this.month; + result.year = this.year; + + this.eventsProvider.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); + } + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + }); + } + + /** + * Navigate to a particular event. + * + * @param {number} eventId Event to load. + */ + gotoEvent(eventId: number): void { + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + this.navCtrl.push('AddonCalendarEventPage', { + id: eventId + }); + } + } + + /** + * Show the context menu. + * + * @param {MouseEvent} event Event. + */ + openCourseFilter(event: MouseEvent): void { + this.coursesHelper.selectCourse(event, this.courses, this.courseId).then((result) => { + if (typeof result.courseId != 'undefined') { + this.courseId = result.courseId > 0 ? result.courseId : undefined; + this.categoryId = result.courseId > 0 ? result.categoryId : undefined; + + // Course viewed has changed, check if the user can create events for this course calendar. + this.calendarHelper.canEditEvents(this.courseId).then((canEdit) => { + this.canCreate = canEdit; + }); + + this.filterEvents(); + } + }); + } + + /** + * Open page to create/edit an event. + * + * @param {number} [eventId] Event ID to edit. + */ + openEdit(eventId?: number): void { + const params: any = {}; + + if (eventId) { + params.eventId = eventId; + } else { + // It's a new event, set the time. + params.timestamp = moment().year(this.year).month(this.month - 1).date(this.day).unix() * 1000; + } + + if (this.courseId) { + params.courseId = this.courseId; + } + + this.navCtrl.push('AddonCalendarEditEventPage', params); + } + + /** + * Calculate current moment. + */ + calculateCurrentMoment(): void { + this.currentMoment = moment().year(this.year).month(this.month - 1).date(this.day); + } + + /** + * Check if user is viewing the current day. + */ + calculateIsCurrentDay(): void { + const now = new Date(); + + this.currentTime = this.timeUtils.timestamp(); + + this.isCurrentDay = this.year == now.getFullYear() && this.month == now.getMonth() + 1 && this.day == now.getDate(); + this.isPastDay = this.year < now.getFullYear() || (this.year == now.getFullYear() && this.month < now.getMonth()) || + (this.year == now.getFullYear() && this.month == now.getMonth() + 1 && this.day < now.getDate()); + } + + /** + * Go to current day. + */ + goToCurrentDay(): void { + const now = new Date(), + initialDay = this.day, + initialMonth = this.month, + initialYear = this.year; + + this.day = now.getDate(); + this.month = now.getMonth() + 1; + this.year = now.getFullYear(); + this.calculateCurrentMoment(); + + this.loaded = false; + + this.fetchEvents().then(() => { + this.isCurrentDay = true; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + + this.year = initialYear; + this.month = initialMonth; + this.day = initialDay; + this.calculateCurrentMoment(); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Load next month. + */ + loadNext(): void { + this.increaseDay(); + + this.loaded = false; + + this.fetchEvents().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.decreaseDay(); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Load previous month. + */ + loadPrevious(): void { + this.decreaseDay(); + + this.loaded = false; + + this.fetchEvents().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.increaseDay(); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Decrease the current day. + */ + protected decreaseDay(): void { + this.currentMoment.subtract(1, 'day'); + + this.year = this.currentMoment.year(); + this.month = this.currentMoment.month() + 1; + this.day = this.currentMoment.date(); + } + + /** + * Increase the current day. + */ + protected increaseDay(): void { + this.currentMoment.add(1, 'day'); + + this.year = this.currentMoment.year(); + this.month = this.currentMoment.month() + 1; + this.day = this.currentMoment.date(); + } + + /** + * Find an event and mark it as deleted. + * + * @param {number} eventId Event ID. + * @param {boolean} deleted Whether to mark it as deleted or not. + * @return {boolean} Whether the event was found. + */ + protected markAsDeleted(eventId: number, deleted: boolean): boolean { + const event = this.onlineEvents.find((event) => { + return event.id == eventId; + }); + + if (event) { + event.deleted = deleted; + + return true; + } + + return false; + } + + /** + * Returns if the event is in the past or not. + * @param {any} event Event object. + * @return {boolean} True if it's in the past. + */ + isEventPast(event: any): boolean { + return (event.timestart + event.timeduration) < this.currentTime; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.newEventObserver && this.newEventObserver.off(); + this.discardedObserver && this.discardedObserver.off(); + this.editEventObserver && this.editEventObserver.off(); + this.deleteEventObserver && this.deleteEventObserver.off(); + this.undeleteEventObserver && this.undeleteEventObserver.off(); + this.syncObserver && this.syncObserver.off(); + this.manualSyncObserver && this.manualSyncObserver.off(); + this.onlineObserver && this.onlineObserver.unsubscribe(); + this.obsDefaultTimeChange && this.obsDefaultTimeChange.off(); + } +} diff --git a/src/addon/calendar/pages/edit-event/edit-event.html b/src/addon/calendar/pages/edit-event/edit-event.html new file mode 100644 index 00000000000..9d79a7e5d9f --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.html @@ -0,0 +1,159 @@ + + + {{ title | translate }} + + + + + + + + +
+ + +

{{ 'addon.calendar.eventname' | translate }}

+ + +
+ + + +

{{ 'core.date' | translate }}

+ + +
+ + + +

{{ 'addon.calendar.eventkind' | translate }}

+ + {{ type.name | translate }} + +
+ + + +

{{ 'core.category' | translate }}

+ + {{ category.name }} + +
+ + + +

{{ 'core.course' | translate }}

+ + {{ course.fullname }} + +
+ + + + + +

{{ 'core.course' | translate }}

+ + {{ course.fullname }} + +
+ + +

{{ 'core.coursenogroups' | translate }}

+
+ + +

{{ 'core.group' | translate }}

+ + {{ group.name }} + +
+ + + + +
+ + + + + {{ 'core.showmore' | translate }} + + {{ 'core.showless' | translate }} + + + + + +

{{ 'core.description' | translate }}

+ +
+ + + +

{{ 'core.location' | translate }}

+ +
+ + +
+

{{ 'addon.calendar.eventduration' | translate }}

+ + {{ 'addon.calendar.durationnone' | translate }} + + + + {{ 'addon.calendar.durationuntil' | translate }} + + + + + + + {{ 'addon.calendar.durationminutes' | translate }} + + + + + +
+ + + + +

{{ 'addon.calendar.repeatevent' | translate }}

+ +
+ +

{{ 'addon.calendar.repeatweeksl' | translate }}

+ +
+
+ + +
+

{{ 'addon.calendar.repeatedevents' | translate }}

+ + {{ 'addon.calendar.repeateditall' | translate:{$a: event.othereventscount} }} + + + + {{ 'addon.calendar.repeateditthis' | translate }} + + +
+
+ + + + + + + + + + + +
+
+
diff --git a/src/addon/calendar/pages/edit-event/edit-event.module.ts b/src/addon/calendar/pages/edit-event/edit-event.module.ts new file mode 100644 index 00000000000..c5b20e969b5 --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonCalendarEditEventPage } from './edit-event'; + +@NgModule({ + declarations: [ + AddonCalendarEditEventPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonCalendarEditEventPage), + TranslateModule.forChild() + ], +}) +export class AddonCalendarEditEventPageModule {} diff --git a/src/addon/calendar/pages/edit-event/edit-event.scss b/src/addon/calendar/pages/edit-event/edit-event.scss new file mode 100644 index 00000000000..6426ce3f192 --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.scss @@ -0,0 +1,35 @@ +ion-app.app-root page-addon-calendar-edit-event { + .addon-calendar-radio-container ion-item:not(.addon-calendar-radio-title) { + &.item-ios { + @include padding-horizontal($item-ios-padding-start * 2, null); + + ion-input { + @include padding-horizontal($datetime-ios-padding-start - $text-input-ios-margin-start, null); + } + } + &.item-md { + @include padding-horizontal($item-md-padding-start * 2, null); + + ion-input { + @include padding-horizontal($datetime-md-padding-start - $text-input-md-margin-start, null); + } + } + &.item-wp { + @include padding-horizontal($item-wp-padding-start * 2, null); + + ion-input { + @include padding-horizontal($datetime-wp-padding-start - $text-input-wp-margin-start, null); + } + } + } + + .addon-calendar-eventtype-container.item-select-disabled { + ion-label, ion-select { + opacity: 1; + } + + .select-icon { + display: none; + } + } +} \ No newline at end of file diff --git a/src/addon/calendar/pages/edit-event/edit-event.ts b/src/addon/calendar/pages/edit-event/edit-event.ts new file mode 100644 index 00000000000..b8d8ef9f409 --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.ts @@ -0,0 +1,596 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, Optional, ViewChild } from '@angular/core'; +import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreRichTextEditorComponent } from '@components/rich-text-editor/rich-text-editor.ts'; +import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; +import { CoreSite } from '@classes/site'; + +/** + * Page that displays a form to create/edit an event. + */ +@IonicPage({ segment: 'addon-calendar-edit-event' }) +@Component({ + selector: 'page-addon-calendar-edit-event', + templateUrl: 'edit-event.html', +}) +export class AddonCalendarEditEventPage implements OnInit, OnDestroy { + + @ViewChild(CoreRichTextEditorComponent) descriptionEditor: CoreRichTextEditorComponent; + + title: string; + dateFormat: string; + component = AddonCalendarProvider.COMPONENT; + loaded = false; + hasOffline = false; + eventTypes = []; + categories = []; + courses = []; + groups = []; + loadingGroups = false; + courseGroupSet = false; + advanced = false; + errors: any; + event: any; // The event object (when editing an event). + + // Form variables. + eventForm: FormGroup; + eventTypeControl: FormControl; + groupControl: FormControl; + descriptionControl: FormControl; + + protected eventId: number; + protected courseId: number; + protected originalData: any; + protected currentSite: CoreSite; + protected types: any; // Object with the supported types. + protected showAll: boolean; + protected isDestroyed = false; + protected error = false; + protected gotEventData = false; + + constructor(navParams: NavParams, + private navCtrl: NavController, + private translate: TranslateService, + private domUtils: CoreDomUtilsProvider, + private textUtils: CoreTextUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, + private eventsProvider: CoreEventsProvider, + private groupsProvider: CoreGroupsProvider, + sitesProvider: CoreSitesProvider, + private coursesProvider: CoreCoursesProvider, + private utils: CoreUtilsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private calendarHelper: AddonCalendarHelperProvider, + private calendarSync: AddonCalendarSyncProvider, + private fb: FormBuilder, + private syncProvider: CoreSyncProvider, + @Optional() private svComponent: CoreSplitViewComponent) { + + this.eventId = navParams.get('eventId'); + this.courseId = navParams.get('courseId'); + this.title = this.eventId ? 'addon.calendar.editevent' : 'addon.calendar.newevent'; + + const timestamp = navParams.get('timestamp'); + + this.currentSite = sitesProvider.getCurrentSite(); + this.errors = { + required: this.translate.instant('core.required') + }; + + // Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them. + this.dateFormat = this.timeUtils.convertPHPToMoment(this.translate.instant('core.strftimedatetimeshort')) + .replace(/[\[\]]/g, ''); + + // Initialize form variables. + this.eventForm = new FormGroup({}); + this.eventTypeControl = this.fb.control('', Validators.required); + this.groupControl = this.fb.control(''); + this.descriptionControl = this.fb.control(''); + + const currentDate = this.timeUtils.toDatetimeFormat(timestamp); + + this.eventForm.addControl('name', this.fb.control('', Validators.required)); + this.eventForm.addControl('timestart', this.fb.control(currentDate, Validators.required)); + this.eventForm.addControl('eventtype', this.eventTypeControl); + this.eventForm.addControl('categoryid', this.fb.control('')); + this.eventForm.addControl('courseid', this.fb.control(this.courseId)); + this.eventForm.addControl('groupcourseid', this.fb.control('')); + this.eventForm.addControl('groupid', this.groupControl); + this.eventForm.addControl('description', this.descriptionControl); + this.eventForm.addControl('location', this.fb.control('')); + this.eventForm.addControl('duration', this.fb.control(0)); + this.eventForm.addControl('timedurationuntil', this.fb.control(currentDate)); + this.eventForm.addControl('timedurationminutes', this.fb.control('')); + this.eventForm.addControl('repeat', this.fb.control(false)); + this.eventForm.addControl('repeats', this.fb.control('1')); + this.eventForm.addControl('repeateditall', this.fb.control(1)); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchData().finally(() => { + this.originalData = this.utils.clone(this.eventForm.value); + this.loaded = true; + }); + } + + /** + * Fetch the data needed to render the form. + * + * @param {boolean} [refresh] Whether it's refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchData(refresh?: boolean): Promise { + let accessInfo; + + this.error = false; + + // Get access info. + return this.calendarProvider.getAccessInformation(this.courseId).then((info) => { + accessInfo = info; + + return this.calendarProvider.getAllowedEventTypes(this.courseId); + }).then((types) => { + this.types = types; + + const promises = [], + eventTypes = this.calendarHelper.getEventTypeOptions(types); + + if (!eventTypes.length) { + return Promise.reject(this.translate.instant('addon.calendar.nopermissiontoupdatecalendar')); + } + + if (this.eventId && !this.gotEventData) { + // Editing an event, get the event data. Wait for sync first. + + promises.push(this.calendarSync.waitForSync(AddonCalendarSyncProvider.SYNC_ID).then(() => { + // Do not block if the scope is already destroyed. + if (!this.isDestroyed) { + this.syncProvider.blockOperation(AddonCalendarProvider.COMPONENT, this.eventId); + } + + const promises = []; + + // Get the event offline data if there's any. + promises.push(this.calendarOffline.getEvent(this.eventId).then((event) => { + this.hasOffline = true; + + return event; + }).catch(() => { + // No offline data. + this.hasOffline = false; + })); + + if (this.eventId > 0) { + // It's an online event. get its data from server. + promises.push(this.calendarProvider.getEventById(this.eventId).then((event) => { + this.event = event; + if (event && event.repeatid) { + event.othereventscount = event.eventcount ? event.eventcount - 1 : ''; + } + + return event; + })); + } + + return Promise.all(promises).then((result) => { + this.gotEventData = true; + + const event = result[0] || result[1]; // Use offline data first. + + if (event) { + // Load the data in the form. + return this.loadEventData(event, !!result[0]); + } + }); + })); + } + + if (types.category) { + // Get the categories. + promises.push(this.coursesProvider.getCategories(0, true).then((cats) => { + this.categories = cats; + })); + } + + this.showAll = this.utils.isTrueOrOne(this.currentSite.getStoredConfig('calendar_adminseesall')) && + accessInfo.canmanageentries; + + if (types.course || types.groups) { + // Get the courses. + const promise = this.showAll ? this.coursesProvider.getCoursesByField() : this.coursesProvider.getUserCourses(); + + promises.push(promise.then((courses) => { + if (this.showAll) { + // Remove site home from the list of courses. + const siteHomeId = this.currentSite.getSiteHomeId(); + courses = courses.filter((course) => { + return course.id != siteHomeId; + }); + } + + // Format the name of the courses. + const subPromises = []; + courses.forEach((course) => { + subPromises.push(this.textUtils.formatText(course.fullname).then((text) => { + course.fullname = text; + }).catch(() => { + // Ignore errors. + })); + }); + + return Promise.all(subPromises).then(() => { + // Sort courses by name. + this.courses = courses.sort((a, b) => { + const compareA = a.fullname.toLowerCase(), + compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + }); + })); + } + + return Promise.all(promises).then(() => { + if (!this.eventTypeControl.value) { + // Initialize event type value. If course is allowed, select it first. + if (types.course) { + this.eventTypeControl.setValue(AddonCalendarProvider.TYPE_COURSE); + } else { + this.eventTypeControl.setValue(eventTypes[0].value); + } + } + + this.eventTypes = eventTypes; + }); + + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting data.'); + this.error = true; + + if (!this.svComponent || !this.svComponent.isOn()) { + this.originalData = null; // Avoid asking for confirmation. + this.navCtrl.pop(); + } + }); + } + + /** + * Load an event data into the form. + * + * @param {any} event Event data. + * @param {boolean} isOffline Whether the data is from offline or not. + * @return {Promise} Promise resolved when done. + */ + protected loadEventData(event: any, isOffline: boolean): Promise { + const courseId = event.course ? event.course.id : event.courseid; + + this.eventForm.controls.name.setValue(event.name); + this.eventForm.controls.timestart.setValue(this.timeUtils.toDatetimeFormat(event.timestart * 1000)); + this.eventForm.controls.eventtype.setValue(event.eventtype); + this.eventForm.controls.categoryid.setValue(event.categoryid || ''); + this.eventForm.controls.courseid.setValue(courseId || ''); + this.eventForm.controls.groupcourseid.setValue(event.groupcourseid || courseId || ''); + this.eventForm.controls.groupid.setValue(event.groupid || ''); + this.eventForm.controls.description.setValue(event.description); + this.eventForm.controls.location.setValue(event.location); + + if (isOffline) { + // It's an offline event, use the data as it is. + this.eventForm.controls.duration.setValue(event.duration); + this.eventForm.controls.timedurationuntil.setValue( + this.timeUtils.toDatetimeFormat((event.timedurationuntil * 1000) || Date.now())); + this.eventForm.controls.timedurationminutes.setValue(event.timedurationminutes || ''); + this.eventForm.controls.repeat.setValue(!!event.repeat); + this.eventForm.controls.repeats.setValue(event.repeats || '1'); + this.eventForm.controls.repeateditall.setValue(event.repeateditall || 1); + } else { + // Online event, we'll have to calculate the data. + + if (event.timeduration > 0) { + this.eventForm.controls.duration.setValue(1); + this.eventForm.controls.timedurationuntil.setValue(this.timeUtils.toDatetimeFormat( + (event.timestart + event.timeduration) * 1000)); + } else { + // No duration. + this.eventForm.controls.duration.setValue(0); + this.eventForm.controls.timedurationuntil.setValue(this.timeUtils.toDatetimeFormat()); + } + + this.eventForm.controls.timedurationminutes.setValue(''); + this.eventForm.controls.repeat.setValue(!!event.repeatid); + this.eventForm.controls.repeats.setValue(event.eventcount || '1'); + this.eventForm.controls.repeateditall.setValue(1); + } + + if (event.eventtype == 'group' && courseId) { + return this.loadGroups(courseId); + } + + return Promise.resolve(); + } + + /** + * Pull to refresh. + * + * @param {any} refresher Refresher. + */ + refreshData(refresher: any): void { + const promises = [ + this.calendarProvider.invalidateAccessInformation(this.courseId), + this.calendarProvider.invalidateAllowedEventTypes(this.courseId) + ]; + + if (this.types) { + if (this.types.category) { + promises.push(this.coursesProvider.invalidateCategories(0, true)); + } + if (this.types.course || this.types.groups) { + if (this.showAll) { + promises.push(this.coursesProvider.invalidateCoursesByField()); + } else { + promises.push(this.coursesProvider.invalidateUserCourses()); + } + } + } + + Promise.all(promises).finally(() => { + this.fetchData(true).finally(() => { + refresher.complete(); + }); + }); + } + + /** + * A course was selected, get its groups. + * + * @param {number} courseId Course ID. + */ + groupCourseSelected(courseId: number): void { + if (!courseId) { + return; + } + + const modal = this.domUtils.showModalLoading(); + + this.loadGroups(courseId).then(() => { + this.groupControl.setValue(''); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting data.'); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Load groups of a certain course. + * + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when done. + */ + protected loadGroups(courseId: number): Promise { + this.loadingGroups = true; + + return this.groupsProvider.getUserGroupsInCourse(courseId).then((groups) => { + this.groups = groups; + this.courseGroupSet = true; + }).finally(() => { + this.loadingGroups = false; + }); + } + + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + + /** + * Create the event. + */ + submit(): void { + // Validate data. + const formData = this.eventForm.value, + timeStartDate = this.timeUtils.convertToTimestamp(formData.timestart), + timeUntilDate = this.timeUtils.convertToTimestamp(formData.timedurationuntil), + timeDurationMinutes = parseInt(formData.timedurationminutes || '', 10); + let error; + + if (formData.eventtype == AddonCalendarProvider.TYPE_COURSE && !formData.courseid) { + error = 'core.selectacourse'; + } else if (formData.eventtype == AddonCalendarProvider.TYPE_GROUP && !formData.groupcourseid) { + error = 'core.selectacourse'; + } else if (formData.eventtype == AddonCalendarProvider.TYPE_GROUP && !formData.groupid) { + error = 'core.selectagroup'; + } else if (formData.eventtype == AddonCalendarProvider.TYPE_CATEGORY && !formData.categoryid) { + error = 'core.selectacategory'; + } else if (formData.duration == 1 && timeStartDate > timeUntilDate) { + error = 'addon.calendar.invalidtimedurationuntil'; + } else if (formData.duration == 2 && (isNaN(timeDurationMinutes) || timeDurationMinutes < 1)) { + error = 'addon.calendar.invalidtimedurationminutes'; + } + + if (error) { + // Show error and stop. + this.domUtils.showErrorModal(this.translate.instant(error)); + + return; + } + + // Format the data to send. + const data: any = { + name: formData.name, + eventtype: formData.eventtype, + timestart: timeStartDate, + description: { + text: formData.description, + format: 1 + }, + location: formData.location, + duration: formData.duration, + repeat: formData.repeat + }; + + if (formData.eventtype == AddonCalendarProvider.TYPE_COURSE) { + data.courseid = formData.courseid; + } else if (formData.eventtype == AddonCalendarProvider.TYPE_GROUP) { + data.groupcourseid = formData.groupcourseid; + data.groupid = formData.groupid; + } else if (formData.eventtype == AddonCalendarProvider.TYPE_CATEGORY) { + data.categoryid = formData.categoryid; + } + + if (formData.duration == 1) { + data.timedurationuntil = timeUntilDate; + } else if (formData.duration == 2) { + data.timedurationminutes = formData.timedurationminutes; + } + + if (formData.repeat) { + data.repeats = Number(formData.repeats); + } + + if (this.event && this.event.repeatid) { + data.repeatid = this.event.repeatid; + data.repeateditall = formData.repeateditall; + } + + // Send the data. + const modal = this.domUtils.showModalLoading('core.sending', true); + let event; + + this.calendarProvider.submitEvent(this.eventId, data).then((result) => { + event = result.event; + + if (result.sent) { + // Event created or edited, invalidate right days & months. + const numberOfRepetitions = formData.repeat ? formData.repeats : + (data.repeateditall && this.event.othereventscount ? this.event.othereventscount + 1 : 1); + + return this.calendarHelper.refreshAfterChangeEvent(result.event, numberOfRepetitions).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + this.returnToList(event); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error sending data.'); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Convenience function to update or return to event list depending on device. + * + * @param {number} [event] Event. + */ + protected returnToList(event?: any): void { + // Unblock the sync because the view will be destroyed and the sync process could be triggered before ngOnDestroy. + this.unblockSync(); + + if (this.eventId > 0) { + // Editing an event. + const data: any = { + event: event + }; + this.eventsProvider.trigger(AddonCalendarProvider.EDIT_EVENT_EVENT, data, this.currentSite.getId()); + } else { + if (event) { + const data: any = { + event: event + }; + this.eventsProvider.trigger(AddonCalendarProvider.NEW_EVENT_EVENT, data, this.currentSite.getId()); + } else { + this.eventsProvider.trigger(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, {}, this.currentSite.getId()); + } + } + + if (this.svComponent && this.svComponent.isOn()) { + // Empty form. + this.hasOffline = false; + this.eventForm.reset(this.originalData); + this.originalData = this.utils.clone(this.eventForm.value); + } else { + this.originalData = null; // Avoid asking for confirmation. + this.navCtrl.pop(); + } + } + + /** + * Discard an offline saved discussion. + */ + discard(): void { + this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => { + this.calendarOffline.deleteEvent(this.eventId).then(() => { + this.returnToList(); + }).catch(() => { + // Shouldn't happen. + this.domUtils.showErrorModal('Error discarding event.'); + }); + }).catch(() => { + // Cancelled. + }); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + + if (this.calendarHelper.hasEventDataChanged(this.eventForm.value, this.originalData)) { + // Show confirmation if some data has been modified. + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } else { + return Promise.resolve(); + } + } + + protected unblockSync(): void { + if (this.eventId) { + this.syncProvider.unblockOperation(AddonCalendarProvider.COMPONENT, this.eventId); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.unblockSync(); + this.isDestroyed = true; + } +} diff --git a/src/addon/calendar/pages/event/event.html b/src/addon/calendar/pages/event/event.html index 3c40c931aff..366327f6661 100644 --- a/src/addon/calendar/pages/event/event.html +++ b/src/addon/calendar/pages/event/event.html @@ -1,39 +1,69 @@ - + + + + + + + + + + + + + + + + - + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendarevent' | translate} }} + + - + + -

+

{{ 'addon.calendar.eventname' | translate }}

+

+ + {{ 'core.deletedoffline' | translate }} +
- -

{{ 'addon.calendar.eventstarttime' | translate}}

-

{{ event.timestart * 1000 | coreFormatDate }}

+ +

{{ 'addon.calendar.when' | translate }}

+

+ + {{ 'core.deletedoffline' | translate }} +
- -

{{ 'addon.calendar.eventendtime' | translate}}

-

{{ (event.timestart + event.timeduration) * 1000 | coreFormatDate }}

+ +

{{ 'addon.calendar.eventtype' | translate }}

+

{{ 'addon.calendar.type' + event.formattedType | translate }}

{{ 'core.course' | translate}}

+ +

{{ 'core.group' | translate}}

+

{{ groupName }}

+

{{ 'core.category' | translate}}

- - {{event.moduleName}} - +

{{ 'core.description' | translate}}

diff --git a/src/addon/calendar/pages/event/event.scss b/src/addon/calendar/pages/event/event.scss new file mode 100644 index 00000000000..6a991373758 --- /dev/null +++ b/src/addon/calendar/pages/event/event.scss @@ -0,0 +1,5 @@ +ion-app.app-root page-addon-calendar-event { + .card ion-note { + font-size: 1.6rem; + } +} diff --git a/src/addon/calendar/pages/event/event.ts b/src/addon/calendar/pages/event/event.ts index 568eac629a8..8edc9b96eac 100644 --- a/src/addon/calendar/pages/event/event.ts +++ b/src/addon/calendar/pages/event/event.ts @@ -12,18 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild } from '@angular/core'; -import { IonicPage, Content, NavParams } from 'ionic-angular'; +import { Component, ViewChild, Optional, OnDestroy, NgZone } from '@angular/core'; +import { IonicPage, Content, NavParams, NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { AddonCalendarProvider } from '../../providers/calendar'; import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreSitesProvider } from '@providers/sites'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { Network } from '@ionic-native/network'; /** * Page that displays a single calendar event. @@ -33,19 +40,25 @@ import { CoreTimeUtilsProvider } from '@providers/utils/time'; selector: 'page-addon-calendar-event', templateUrl: 'event.html', }) -export class AddonCalendarEventPage { +export class AddonCalendarEventPage implements OnDestroy { @ViewChild(Content) content: Content; protected eventId; protected siteHomeId: number; + protected editEventObserver: any; + protected syncObserver: any; + protected manualSyncObserver: any; + protected onlineObserver: any; + protected currentSiteId: string; + eventLoaded: boolean; notificationFormat: string; notificationMin: string; notificationMax: string; notificationTimeText: string; event: any = {}; - title: string; courseName: string; + groupName: string; courseUrl = ''; notificationsEnabled = false; moduleUrl = ''; @@ -53,16 +66,33 @@ export class AddonCalendarEventPage { currentTime: number; defaultTime: number; reminders: any[]; + canEdit = false; + canDelete = false; + hasOffline = false; + isOnline = false; + syncIcon: string; // Sync icon. + isSplitViewOn = false; constructor(private translate: TranslateService, private calendarProvider: AddonCalendarProvider, navParams: NavParams, private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private calendarHelper: AddonCalendarHelperProvider, private sitesProvider: CoreSitesProvider, localNotificationsProvider: CoreLocalNotificationsProvider, private courseProvider: CoreCourseProvider, - private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, + private groupsProvider: CoreGroupsProvider, @Optional() private svComponent: CoreSplitViewComponent, + private navCtrl: NavController, private eventsProvider: CoreEventsProvider, network: Network, zone: NgZone, + private calendarSync: AddonCalendarSyncProvider, private appProvider: CoreAppProvider, + private calendarOffline: AddonCalendarOfflineProvider) { this.eventId = navParams.get('id'); this.notificationsEnabled = localNotificationsProvider.isAvailable(); this.siteHomeId = sitesProvider.getCurrentSite().getSiteHomeId(); + this.currentSiteId = sitesProvider.getCurrentSiteId(); + this.isSplitViewOn = this.svComponent && this.svComponent.isOn(); + + // Check if site supports editing and deleting. No need to check allowed types, event.canedit already does it. + this.canEdit = this.calendarProvider.canEditEventsInSite(); + this.canDelete = this.calendarProvider.canDeleteEventsInSite(); + if (this.notificationsEnabled) { this.calendarProvider.getEventReminders(this.eventId).then((reminders) => { this.reminders = reminders; @@ -72,38 +102,123 @@ export class AddonCalendarEventPage { this.defaultTime = defaultTime * 60; }); - // Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them. - this.notificationFormat = this.timeUtils.convertPHPToMoment(this.translate.instant('core.strftimedatetimeshort')) - .replace(/[\[\]]/g, ''); + // Calculate format to use. + this.notificationFormat = this.timeUtils.fixFormatForDatetime(this.timeUtils.convertPHPToMoment( + this.translate.instant('core.strftimedatetime'))); } + + // Listen for event edited. If current event is edited, reload the data. + this.editEventObserver = eventsProvider.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data) => { + if (data && data.event && data.event.id == this.eventId) { + this.eventLoaded = false; + this.refreshEvent(true, false); + } + }, this.currentSiteId); + + // Refresh data if this calendar event is synchronized automatically. + this.syncObserver = eventsProvider.on(AddonCalendarSyncProvider.AUTO_SYNCED, this.checkSyncResult.bind(this, false), + this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = eventsProvider.on(AddonCalendarSyncProvider.MANUAL_SYNCED, this.checkSyncResult.bind(this, true), + this.currentSiteId); + + // Refresh online status when changes. + this.onlineObserver = network.onchange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this.isOnline = this.appProvider.isOnline(); + }); + }); } /** * View loaded. */ ionViewDidLoad(): void { - this.fetchEvent().finally(() => { - this.eventLoaded = true; - }); + this.syncIcon = 'spinner'; + + this.fetchEvent(); } /** * Fetches the event and updates the view. * + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. * @return {Promise} Promise resolved when done. */ - fetchEvent(): Promise { + fetchEvent(sync?: boolean, showErrors?: boolean): Promise { const currentSite = this.sitesProvider.getCurrentSite(), - canGetById = this.calendarProvider.isGetEventByIdAvailable(); - let promise; + canGetById = this.calendarProvider.isGetEventByIdAvailableInSite(); + let promise, + deleted = false; + + this.isOnline = this.appProvider.isOnline(); - if (canGetById) { - promise = this.calendarProvider.getEventById(this.eventId); + if (sync) { + // Try to synchronize offline events. + promise = this.calendarSync.syncEvents().then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } + + if (result.deleted && result.deleted.indexOf(this.eventId) != -1) { + // This event was deleted during the sync. + deleted = true; + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'event'; + + this.eventsProvider.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); + } + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + }); } else { - promise = this.calendarProvider.getEvent(this.eventId); + promise = Promise.resolve(); } - return promise.then((event) => { + return promise.then(() => { + if (deleted) { + return; + } + + const promises = []; + + // Get the event data. + if (canGetById) { + promises.push(this.calendarProvider.getEventById(this.eventId)); + } else { + promises.push(this.calendarProvider.getEvent(this.eventId)); + } + + // Get offline data. + promises.push(this.calendarOffline.getEvent(this.eventId).catch(() => { + // No offline data. + })); + + return Promise.all(promises).then((results) => { + if (results[1]) { + // There is offline data, apply it. + this.hasOffline = true; + Object.assign(results[0], results[1]); + } else { + this.hasOffline = false; + } + + return results[0]; + }); + + }).then((event) => { + if (deleted) { + return; + } + const promises = []; this.calendarHelper.formatEventData(event); @@ -120,8 +235,6 @@ export class AddonCalendarEventPage { this.courseUrl = ''; this.moduleUrl = ''; - // Guess event title. - let title = this.translate.instant('addon.calendar.type' + event.eventtype); if (event.moduleIcon) { // It's a module event, translate the module name to the current language. const name = this.courseProvider.translateModuleName(event.modulename); @@ -129,29 +242,14 @@ export class AddonCalendarEventPage { event.moduleName = name; } - // Calculate the title of the page; - if (title == 'addon.calendar.type' + event.eventtype) { - title = this.translate.instant('core.mod_' + event.modulename + '.' + event.eventtype); - - if (title == 'core.mod_' + event.modulename + '.' + event.eventtype) { - title = name; - } - } - // Get the module URL. if (canGetById) { this.moduleUrl = event.url; } - } else { - if (title == 'addon.calendar.type' + event.eventtype) { - title = event.name; - } } - this.title = title; - // If the event belongs to a course, get the course name and the URL to view it. - if (canGetById && event.course) { + if (canGetById && event.course && event.course.id != this.siteHomeId) { this.courseName = event.course.fullname; this.courseUrl = event.course.viewurl; } else if (event.courseid && event.courseid != this.siteHomeId) { @@ -165,7 +263,22 @@ export class AddonCalendarEventPage { })); } - if (canGetById && event.iscategoryevent) { + // If it's a group event, get the name of the group. + const courseId = canGetById && event.course ? event.course.id : event.courseid; + if (courseId && event.groupid) { + promises.push(this.groupsProvider.getUserGroupsInCourse(event.courseid).then((groups) => { + const group = groups.find((group) => { + return group.id == event.groupid; + }); + + this.groupName = group ? group.name : ''; + }).catch(() => { + // Error getting groups, just don't show the group name. + this.groupName = ''; + })); + } + + if (canGetById && event.iscategoryevent && event.category) { this.categoryPath = event.category.nestedname; } @@ -175,9 +288,24 @@ export class AddonCalendarEventPage { event.encodedLocation = this.textUtils.buildAddressURL(event.location); } + // Check if event was deleted in offine. + promises.push(this.calendarOffline.isEventDeleted(this.eventId).then((deleted) => { + event.deleted = deleted; + })); + + // Re-calculate the formatted time so it uses the device date. + promises.push(this.calendarProvider.getCalendarTimeFormat().then((timeFormat) => { + this.calendarProvider.formatEventTime(event, timeFormat).then((time) => { + event.formattedtime = time; + }); + })); + return Promise.all(promises); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevent', true); + }).finally(() => { + this.eventLoaded = true; + this.syncIcon = 'sync'; }); } @@ -228,16 +356,195 @@ export class AddonCalendarEventPage { }); } + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void, showErrors?: boolean): Promise { + if (this.eventLoaded) { + return this.refreshEvent(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); + } + + return Promise.resolve(); + } + /** * Refresh the event. * - * @param {any} refresher Refresher. + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + refreshEvent(sync?: boolean, showErrors?: boolean): Promise { + this.syncIcon = 'spinner'; + + const promises = []; + + promises.push(this.calendarProvider.invalidateEvent(this.eventId)); + promises.push(this.calendarProvider.invalidateTimeFormat()); + + return Promise.all(promises).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchEvent(sync, showErrors); + }); + } + + /** + * Open the page to edit the event. + */ + openEdit(): void { + // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav. + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + navCtrl.push('AddonCalendarEditEventPage', {eventId: this.eventId}); + } + + /** + * Delete the event. */ - refreshEvent(refresher: any): void { - this.calendarProvider.invalidateEvent(this.eventId).finally(() => { - this.fetchEvent().finally(() => { - refresher.complete(); + deleteEvent(): void { + const title = this.translate.instant('addon.calendar.deleteevent'), + options: any = {}; + let message: string; + + if (this.event.eventcount > 1) { + // It's a repeated event. + message = this.translate.instant('addon.calendar.confirmeventseriesdelete', + {$a: {name: this.event.name, count: this.event.eventcount}}); + + options.inputs = [ + { + type: 'radio', + name: 'deleteall', + checked: true, + value: false, + label: this.translate.instant('addon.calendar.deleteoneevent') + }, + { + type: 'radio', + name: 'deleteall', + checked: false, + value: true, + label: this.translate.instant('addon.calendar.deleteallevents') + } + ]; + } else { + // Not repeated, display a simple confirm. + message = this.translate.instant('addon.calendar.confirmeventdelete', {$a: this.event.name}); + } + + this.domUtils.showConfirm(message, title, undefined, undefined, options).then((deleteAll) => { + + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.calendarProvider.deleteEvent(this.event.id, this.event.name, deleteAll).then((sent) => { + let promise; + + if (sent) { + // Event deleted, invalidate right days & months. + promise = this.calendarHelper.refreshAfterChangeEvent(this.event, deleteAll ? this.event.eventcount : 1) + .catch(() => { + // Ignore errors. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + // Trigger an event. + this.eventsProvider.trigger(AddonCalendarProvider.DELETED_EVENT_EVENT, { + eventId: this.eventId, + sent: sent + }, this.sitesProvider.getCurrentSiteId()); + + if (sent) { + this.domUtils.showToast('addon.calendar.eventcalendareventdeleted', true, 3000, undefined, false); + + // Event deleted, close the view. + if (!this.svComponent || !this.svComponent.isOn()) { + this.navCtrl.pop(); + } + } else { + // Event deleted in offline, just mark it as deleted. + this.event.deleted = true; + } + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error deleting event.'); + }).finally(() => { + modal.dismiss(); }); + }, () => { + // User canceled. }); } + + /** + * Undo delete the event. + */ + undoDelete(): void { + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.calendarOffline.unmarkDeleted(this.event.id).then(() => { + + // Trigger an event. + this.eventsProvider.trigger(AddonCalendarProvider.UNDELETED_EVENT_EVENT, { + eventId: this.eventId + }, this.sitesProvider.getCurrentSiteId()); + + this.event.deleted = false; + + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error undeleting event.'); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Check the result of an automatic sync or a manual sync not done by this page. + * + * @param {boolean} isManual Whether it's a manual sync. + * @param {any} data Sync result. + */ + protected checkSyncResult(isManual: boolean, data: any): void { + if (!data) { + return; + } + + if (data.deleted && data.deleted.indexOf(this.eventId) != -1) { + this.domUtils.showToast('addon.calendar.eventcalendareventdeleted', true, 3000, undefined, false); + + // Event was deleted, close the view. + if (!this.svComponent || !this.svComponent.isOn()) { + this.navCtrl.pop(); + } + } else if (data.events && (!isManual || data.source != 'event')) { + const event = data.events.find((ev) => { + return ev.id == this.eventId; + }); + + if (event) { + this.eventLoaded = false; + this.refreshEvent(); + } + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.editEventObserver && this.editEventObserver.off(); + this.syncObserver && this.syncObserver.off(); + this.manualSyncObserver && this.manualSyncObserver.off(); + this.onlineObserver && this.onlineObserver.unsubscribe(); + } } diff --git a/src/addon/calendar/pages/index/index.html b/src/addon/calendar/pages/index/index.html new file mode 100644 index 00000000000..d2d6d38b089 --- /dev/null +++ b/src/addon/calendar/pages/index/index.html @@ -0,0 +1,38 @@ + + + {{ (showCalendar ? 'addon.calendar.calendarevents' : 'addon.calendar.upcomingevents') | translate }} + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendar' | translate} }} + + + + + + + + + + + diff --git a/src/addon/calendar/pages/index/index.module.ts b/src/addon/calendar/pages/index/index.module.ts new file mode 100644 index 00000000000..bf925e79931 --- /dev/null +++ b/src/addon/calendar/pages/index/index.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonCalendarComponentsModule } from '../../components/components.module'; +import { AddonCalendarIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonCalendarIndexPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + AddonCalendarComponentsModule, + IonicPageModule.forChild(AddonCalendarIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonCalendarIndexPageModule {} diff --git a/src/addon/calendar/pages/index/index.ts b/src/addon/calendar/pages/index/index.ts new file mode 100644 index 00000000000..8f3d5a129da --- /dev/null +++ b/src/addon/calendar/pages/index/index.ts @@ -0,0 +1,379 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OFx ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, ViewChild, NgZone } from '@angular/core'; +import { IonicPage, NavParams, NavController } from 'ionic-angular'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarCalendarComponent } from '../../components/calendar/calendar'; +import { AddonCalendarUpcomingEventsComponent } from '../../components/upcoming-events/upcoming-events'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; +import { CoreCoursesHelperProvider } from '@core/courses/providers/helper'; +import { Network } from '@ionic-native/network'; + +/** + * Page that displays the calendar events. + */ +@IonicPage({ segment: 'addon-calendar-index' }) +@Component({ + selector: 'page-addon-calendar-index', + templateUrl: 'index.html', +}) +export class AddonCalendarIndexPage implements OnInit, OnDestroy { + @ViewChild(AddonCalendarCalendarComponent) calendarComponent: AddonCalendarCalendarComponent; + @ViewChild(AddonCalendarUpcomingEventsComponent) upcomingEventsComponent: AddonCalendarUpcomingEventsComponent; + + protected eventId: number; + protected currentSiteId: string; + + // Observers. + protected newEventObserver: any; + protected discardedObserver: any; + protected editEventObserver: any; + protected deleteEventObserver: any; + protected undeleteEventObserver: any; + protected syncObserver: any; + protected manualSyncObserver: any; + protected onlineObserver: any; + + year: number; + month: number; + courseId: number; + categoryId: number; + canCreate = false; + courses: any[]; + notificationsEnabled = false; + loaded = false; + hasOffline = false; + isOnline = false; + syncIcon: string; + showCalendar = true; + loadUpcoming = false; + + constructor(localNotificationsProvider: CoreLocalNotificationsProvider, + navParams: NavParams, + network: Network, + zone: NgZone, + sitesProvider: CoreSitesProvider, + private navCtrl: NavController, + private domUtils: CoreDomUtilsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private calendarHelper: AddonCalendarHelperProvider, + private calendarSync: AddonCalendarSyncProvider, + private eventsProvider: CoreEventsProvider, + private coursesHelper: CoreCoursesHelperProvider, + private appProvider: CoreAppProvider) { + + this.courseId = navParams.get('courseId'); + this.eventId = navParams.get('eventId') || false; + this.year = navParams.get('year'); + this.month = navParams.get('month'); + this.notificationsEnabled = localNotificationsProvider.isAvailable(); + this.currentSiteId = sitesProvider.getCurrentSiteId(); + this.loadUpcoming = !!navParams.get('upcoming'); + this.showCalendar = !this.loadUpcoming; + + // Listen for events added. When an event is added, reload the data. + this.newEventObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => { + if (data && data.event) { + this.loaded = false; + this.refreshData(true, false, true); + } + }, this.currentSiteId); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + this.loaded = false; + this.refreshData(true, false, true); + }, this.currentSiteId); + + // Listen for events edited. When an event is edited, reload the data. + this.editEventObserver = eventsProvider.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data) => { + if (data && data.event) { + this.loaded = false; + this.refreshData(true, false, true); + } + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = eventsProvider.on(AddonCalendarSyncProvider.AUTO_SYNCED, (data) => { + this.loaded = false; + this.refreshData(false, false, true); + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = eventsProvider.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => { + if (data && data.source != 'index') { + this.loaded = false; + this.refreshData(false, false, true); + } + }, this.currentSiteId); + + // Update the events when an event is deleted. + this.deleteEventObserver = eventsProvider.on(AddonCalendarProvider.DELETED_EVENT_EVENT, (data) => { + this.loaded = false; + this.refreshData(false, false, true); + }, this.currentSiteId); + + // Update the "hasOffline" property if an event deleted in offline is restored. + this.undeleteEventObserver = eventsProvider.on(AddonCalendarProvider.UNDELETED_EVENT_EVENT, (data) => { + this.calendarOffline.hasOfflineData().then((hasOffline) => { + this.hasOffline = hasOffline; + }); + }, this.currentSiteId); + + // Refresh online status when changes. + this.onlineObserver = network.onchange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this.isOnline = this.appProvider.isOnline(); + }); + }); + } + + /** + * View loaded. + */ + ngOnInit(): void { + if (this.eventId) { + // There is an event to load, open the event in a new state. + this.gotoEvent(this.eventId); + } + + this.fetchData(true, false); + } + + /** + * Fetch all the data required for the view. + * + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + fetchData(sync?: boolean, showErrors?: boolean): Promise { + + this.syncIcon = 'spinner'; + this.isOnline = this.appProvider.isOnline(); + + let promise; + + if (sync) { + // Try to synchronize offline events. + promise = this.calendarSync.syncEvents().then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'index'; + + this.eventsProvider.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); + } + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + const promises = []; + + this.hasOffline = false; + + // Load courses for the popover. + promises.push(this.coursesHelper.getCoursesForPopover(this.courseId).then((data) => { + this.courses = data.courses; + this.categoryId = data.categoryId; + })); + + // Check if user can create events. + promises.push(this.calendarHelper.canEditEvents(this.courseId).then((canEdit) => { + this.canCreate = canEdit; + })); + + // Check if there is offline data. + promises.push(this.calendarOffline.hasOfflineData().then((hasOffline) => { + this.hasOffline = hasOffline; + })); + + return Promise.all(promises); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + }).finally(() => { + this.loaded = true; + this.syncIcon = 'sync'; + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void, showErrors?: boolean): Promise { + if (this.loaded) { + return this.refreshData(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); + } + + return Promise.resolve(); + } + + /** + * Refresh the data. + * + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @param {boolean} [afterChange] Whether the refresh is done after an event has changed or has been synced. + * @return {Promise} Promise resolved when done. + */ + refreshData(sync?: boolean, showErrors?: boolean, afterChange?: boolean): Promise { + this.syncIcon = 'spinner'; + + const promises = []; + + promises.push(this.calendarProvider.invalidateAllowedEventTypes()); + + // Refresh the sub-component. + if (this.showCalendar && this.calendarComponent) { + promises.push(this.calendarComponent.refreshData(afterChange)); + } else if (!this.showCalendar && this.upcomingEventsComponent) { + promises.push(this.upcomingEventsComponent.refreshData(afterChange)); + } + + return Promise.all(promises).finally(() => { + return this.fetchData(sync, showErrors); + }); + } + + /** + * Navigate to a particular event. + * + * @param {number} eventId Event to load. + */ + gotoEvent(eventId: number): void { + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + this.navCtrl.push('AddonCalendarEventPage', { + id: eventId + }); + } + } + + /** + * View a certain day. + * + * @param {any} data Data with the year, month and day. + */ + gotoDay(data: any): void { + const params: any = { + day: data.day, + month: data.month, + year: data.year + }; + + if (this.courseId) { + params.courseId = this.courseId; + } + + this.navCtrl.push('AddonCalendarDayPage', params); + } + + /** + * Show the context menu. + * + * @param {MouseEvent} event Event. + */ + openCourseFilter(event: MouseEvent): void { + this.coursesHelper.selectCourse(event, this.courses, this.courseId).then((result) => { + if (typeof result.courseId != 'undefined') { + this.courseId = result.courseId > 0 ? result.courseId : undefined; + this.categoryId = result.courseId > 0 ? result.categoryId : undefined; + + // Course viewed has changed, check if the user can create events for this course calendar. + this.calendarHelper.canEditEvents(this.courseId).then((canEdit) => { + this.canCreate = canEdit; + }); + } + }); + } + + /** + * Open page to create/edit an event. + * + * @param {number} [eventId] Event ID to edit. + */ + openEdit(eventId?: number): void { + const params: any = {}; + + if (eventId) { + params.eventId = eventId; + } + if (this.courseId) { + params.courseId = this.courseId; + } + + this.navCtrl.push('AddonCalendarEditEventPage', params); + } + + /** + * Open calendar events settings. + */ + openSettings(): void { + this.navCtrl.push('AddonCalendarSettingsPage'); + } + + /** + * Toogle display: monthly view or upcoming events. + */ + toggleDisplay(): void { + this.showCalendar = !this.showCalendar; + + if (!this.showCalendar) { + this.loadUpcoming = true; + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.newEventObserver && this.newEventObserver.off(); + this.discardedObserver && this.discardedObserver.off(); + this.editEventObserver && this.editEventObserver.off(); + this.deleteEventObserver && this.deleteEventObserver.off(); + this.undeleteEventObserver && this.undeleteEventObserver.off(); + this.syncObserver && this.syncObserver.off(); + this.manualSyncObserver && this.manualSyncObserver.off(); + this.onlineObserver && this.onlineObserver.unsubscribe(); + } +} diff --git a/src/addon/calendar/pages/list/list.html b/src/addon/calendar/pages/list/list.html index e5cdc3eb09b..3ae4f4c0d52 100644 --- a/src/addon/calendar/pages/list/list.html +++ b/src/addon/calendar/pages/list/list.html @@ -3,20 +3,27 @@ {{ 'addon.calendar.calendarevents' | translate }} + - + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendar' | translate} }} + + @@ -25,7 +32,7 @@ {{ event.timestart * 1000 | coreFormatDate: "strftimedayshort" }} - +

@@ -34,11 +41,26 @@

- {{ (event.timestart + event.timeduration) * 1000 | coreFormatDate: "strftimetime" }} - {{ (event.timestart + event.timeduration) * 1000 | coreFormatDate: "strftimedatetimeshort" }}

+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} +
+ + + + +
\ No newline at end of file diff --git a/src/addon/calendar/pages/list/list.scss b/src/addon/calendar/pages/list/list.scss new file mode 100644 index 00000000000..9f40d974653 --- /dev/null +++ b/src/addon/calendar/pages/list/list.scss @@ -0,0 +1,5 @@ +ion-app.app-root page-addon-calendar-list { + ion-note { + max-width: 30%; + } +} diff --git a/src/addon/calendar/pages/list/list.ts b/src/addon/calendar/pages/list/list.ts index cd8523d9007..0b864a78ab9 100644 --- a/src/addon/calendar/pages/list/list.ts +++ b/src/addon/calendar/pages/list/list.ts @@ -12,21 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, OnDestroy } from '@angular/core'; -import { IonicPage, Content, PopoverController, NavParams, NavController } from 'ionic-angular'; -import { TranslateService } from '@ngx-translate/core'; +import { Component, ViewChild, OnDestroy, NgZone } from '@angular/core'; +import { IonicPage, Content, NavParams, NavController } from 'ionic-angular'; import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreCoursesHelperProvider } from '@core/courses/providers/helper'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitesProvider } from '@providers/sites'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; -import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover'; import { CoreEventsProvider } from '@providers/events'; import { CoreAppProvider } from '@providers/app'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import * as moment from 'moment'; +import { Network } from '@ionic-native/network'; +import { CoreConstants } from '@core/constants'; /** * Page that displays the list of calendar events. @@ -40,47 +44,167 @@ export class AddonCalendarListPage implements OnDestroy { @ViewChild(Content) content: Content; @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + protected initialTime = 0; protected daysLoaded = 0; protected emptyEventsTimes = 0; // Variable to identify consecutive calls returning 0 events. protected categoriesRetrieved = false; protected getCategories = false; - protected allCourses = { - id: -1, - fullname: this.translate.instant('core.fulllistofcourses'), - category: -1 - }; protected categories = {}; protected siteHomeId: number; protected obsDefaultTimeChange: any; protected eventId: number; + protected newEventObserver: any; + protected discardedObserver: any; + protected editEventObserver: any; + protected deleteEventObserver: any; + protected undeleteEventObserver: any; + protected syncObserver: any; + protected manualSyncObserver: any; + protected onlineObserver: any; + protected currentSiteId: string; + protected onlineEvents = []; + protected offlineEvents = []; + protected deletedEvents = []; courses: any[]; eventsLoaded = false; - events = []; + events = []; // Events (both online and offline). notificationsEnabled = false; filteredEvents = []; canLoadMore = false; loadMoreError = false; - filter = { - course: this.allCourses - }; - - constructor(private translate: TranslateService, private calendarProvider: AddonCalendarProvider, navParams: NavParams, + courseId: number; + categoryId: number; + canCreate = false; + hasOffline = false; + isOnline = false; + syncIcon: string; // Sync icon. + + constructor(private calendarProvider: AddonCalendarProvider, navParams: NavParams, private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider, - private calendarHelper: AddonCalendarHelperProvider, sitesProvider: CoreSitesProvider, - localNotificationsProvider: CoreLocalNotificationsProvider, private popoverCtrl: PopoverController, - eventsProvider: CoreEventsProvider, private navCtrl: NavController, appProvider: CoreAppProvider) { + private calendarHelper: AddonCalendarHelperProvider, sitesProvider: CoreSitesProvider, zone: NgZone, + localNotificationsProvider: CoreLocalNotificationsProvider, private coursesHelper: CoreCoursesHelperProvider, + private eventsProvider: CoreEventsProvider, private navCtrl: NavController, private appProvider: CoreAppProvider, + private calendarOffline: AddonCalendarOfflineProvider, private calendarSync: AddonCalendarSyncProvider, + network: Network, private timeUtils: CoreTimeUtilsProvider) { this.siteHomeId = sitesProvider.getCurrentSite().getSiteHomeId(); this.notificationsEnabled = localNotificationsProvider.isAvailable(); + this.currentSiteId = sitesProvider.getCurrentSiteId(); + if (this.notificationsEnabled) { // Re-schedule events if default time changes. this.obsDefaultTimeChange = eventsProvider.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { - calendarProvider.scheduleEventsNotifications(this.events); - }, sitesProvider.getCurrentSiteId()); + calendarProvider.scheduleEventsNotifications(this.onlineEvents); + }, this.currentSiteId); } this.eventId = navParams.get('eventId') || false; + this.courseId = navParams.get('courseId'); + + // Listen for events added. When an event is added, reload the data. + this.newEventObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => { + if (data && data.event) { + if (this.splitviewCtrl.isOn()) { + // Discussion added, clear details page. + this.splitviewCtrl.emptyDetails(); + } + + this.eventsLoaded = false; + this.refreshEvents(true, false).finally(() => { + + // In tablet mode try to open the event (only if it's an online event). + if (this.splitviewCtrl.isOn() && data.event.id > 0) { + this.gotoEvent(data.event.id); + } + }); + } + }, this.currentSiteId); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + if (this.splitviewCtrl.isOn()) { + // Discussion added, clear details page. + this.splitviewCtrl.emptyDetails(); + } + + this.eventsLoaded = false; + this.refreshEvents(true, false); + }, this.currentSiteId); + + // Listen for events edited. When an event is edited, reload the data. + this.editEventObserver = eventsProvider.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data) => { + if (data && data.event) { + this.eventsLoaded = false; + this.refreshEvents(true, false); + } + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = eventsProvider.on(AddonCalendarSyncProvider.AUTO_SYNCED, (data) => { + this.eventsLoaded = false; + this.refreshEvents(); + + if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + // Current selected event was deleted. Clear details. + this.splitviewCtrl.emptyDetails(); + } + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = eventsProvider.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => { + if (data && data.source != 'list') { + this.eventsLoaded = false; + this.refreshEvents(); + } + + if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + // Current selected event was deleted. Clear details. + this.splitviewCtrl.emptyDetails(); + } + }, this.currentSiteId); + + // Update the list when an event is deleted. + this.deleteEventObserver = eventsProvider.on(AddonCalendarProvider.DELETED_EVENT_EVENT, (data) => { + if (data && !data.sent) { + // Event was deleted in offline. Just mark it as deleted, no need to refresh. + this.markAsDeleted(data.eventId, true); + this.deletedEvents.push(data.eventId); + this.hasOffline = true; + } else { + // Event deleted, clear the details if needed and refresh the view. + if (this.splitviewCtrl.isOn()) { + this.splitviewCtrl.emptyDetails(); + } + + this.eventsLoaded = false; + this.refreshEvents(); + } + }, this.currentSiteId); + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = eventsProvider.on(AddonCalendarProvider.UNDELETED_EVENT_EVENT, (data) => { + if (data && data.eventId) { + // Mark it as undeleted, no need to refresh. + this.markAsDeleted(data.eventId, false); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + + this.hasOffline = !!this.offlineEvents.length || !!this.deletedEvents.length; + } + }, this.currentSiteId); + + // Refresh online status when changes. + this.onlineObserver = network.onchange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this.isOnline = this.appProvider.isOnline(); + }); + }); } /** @@ -92,13 +216,17 @@ export class AddonCalendarListPage implements OnDestroy { this.gotoEvent(this.eventId); } - this.fetchData().then(() => { + this.syncIcon = 'spinner'; + + this.fetchData(false, true, false).then(() => { if (!this.eventId && this.splitviewCtrl.isOn() && this.events.length > 0) { - // Take first and load it. - this.gotoEvent(this.events[0].id); + // Take first online event and load it. If no online event, load the first offline. + if (this.onlineEvents[0]) { + this.gotoEvent(this.onlineEvents[0].id); + } else { + this.gotoEvent(this.offlineEvents[0].id); + } } - }).finally(() => { - this.eventsLoaded = true; }); } @@ -106,19 +234,80 @@ export class AddonCalendarListPage implements OnDestroy { * Fetch all the data required for the view. * * @param {boolean} [refresh] Empty events array first. + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. * @return {Promise} Promise resolved when done. */ - fetchData(refresh: boolean = false): Promise { + fetchData(refresh?: boolean, sync?: boolean, showErrors?: boolean): Promise { + this.initialTime = this.timeUtils.timestamp(); this.daysLoaded = 0; this.emptyEventsTimes = 0; + this.isOnline = this.appProvider.isOnline(); + + let promise; + + if (sync) { + // Try to synchronize offline events. + promise = this.calendarSync.syncEvents().then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } - // Load courses for the popover. - return this.coursesProvider.getUserCourses(false).then((courses) => { - // Add "All courses". - courses.unshift(this.allCourses); - this.courses = courses; + if (result.updated) { + // Trigger a manual sync event. + result.source = 'list'; - return this.fetchEvents(refresh); + this.eventsProvider.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); + } + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + + const promises = []; + + this.hasOffline = false; + + promises.push(this.calendarHelper.canEditEvents(this.courseId).then((canEdit) => { + this.canCreate = canEdit; + })); + + // Load courses for the popover. + promises.push(this.coursesHelper.getCoursesForPopover(this.courseId).then((result) => { + this.courses = result.courses; + this.categoryId = result.categoryId; + + return this.fetchEvents(refresh); + })); + + // Get offline events. + promises.push(this.calendarOffline.getAllEditedEvents().then((events) => { + this.hasOffline = this.hasOffline || !!events.length; + + // Format data and sort by timestart. + events.forEach((event) => { + event.offline = true; + this.calendarHelper.formatEventData(event); + }); + this.offlineEvents = this.sortEvents(events); + })); + + // Get events deleted in offline. + promises.push(this.calendarOffline.getAllDeletedEventsIds().then((ids) => { + this.hasOffline = this.hasOffline || !!ids.length; + this.deletedEvents = ids; + })); + + return Promise.all(promises); + }).finally(() => { + this.eventsLoaded = true; + this.syncIcon = 'sync'; }); } @@ -128,40 +317,41 @@ export class AddonCalendarListPage implements OnDestroy { * @param {boolean} [refresh] Empty events array first. * @return {Promise} Promise resolved when done. */ - fetchEvents(refresh: boolean = false): Promise { + fetchEvents(refresh?: boolean): Promise { this.loadMoreError = false; - return this.calendarProvider.getEventsList(this.daysLoaded, AddonCalendarProvider.DAYS_INTERVAL).then((events) => { - this.daysLoaded += AddonCalendarProvider.DAYS_INTERVAL; - if (events.length === 0) { + return this.calendarProvider.getEventsList(this.initialTime, this.daysLoaded, AddonCalendarProvider.DAYS_INTERVAL) + .then((onlineEvents) => { + + if (onlineEvents.length === 0) { this.emptyEventsTimes++; if (this.emptyEventsTimes > 5) { // Stop execution if we retrieve empty list 6 consecutive times. this.canLoadMore = false; if (refresh) { - this.events = []; + this.onlineEvents = []; this.filteredEvents = []; + this.events = this.offlineEvents; } } else { // No events returned, load next events. + this.daysLoaded += AddonCalendarProvider.DAYS_INTERVAL; + return this.fetchEvents(); } } else { - // Sort the events by timestart, they're ordered by id. - events.sort((a, b) => { - if (a.timestart == b.timestart) { - return a.timeduration - b.timeduration; - } + onlineEvents.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); - return a.timestart - b.timestart; - }); + // Get the merged events of this period. + const events = this.mergeEvents(onlineEvents); - events.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); - this.getCategories = this.shouldLoadCategories(events); + this.getCategories = this.shouldLoadCategories(onlineEvents); if (refresh) { + this.onlineEvents = onlineEvents; this.events = events; } else { // Filter events with same ID. Repeated events are returned once per WS call, show them only once. + this.onlineEvents = this.utils.mergeArraysWithoutDuplicates(this.onlineEvents, onlineEvents, 'id'); this.events = this.utils.mergeArraysWithoutDuplicates(this.events, events, 'id'); } this.filteredEvents = this.getFilteredEvents(); @@ -174,7 +364,9 @@ export class AddonCalendarListPage implements OnDestroy { this.canLoadMore = true; // Schedule notifications for the events retrieved (might have new events). - this.calendarProvider.scheduleEventsNotifications(this.events); + this.calendarProvider.scheduleEventsNotifications(this.onlineEvents); + + this.daysLoaded += AddonCalendarProvider.DAYS_INTERVAL; } // Resize the content so infinite loading is able to calculate if it should load more items or not. @@ -211,55 +403,14 @@ export class AddonCalendarListPage implements OnDestroy { * @return {any[]} Filtered events. */ protected getFilteredEvents(): any[] { - if (this.filter.course.id == -1) { + if (!this.courseId) { // No filter, display everything. return this.events; } - return this.events.filter(this.shouldDisplayEvent.bind(this)); - } - - /** - * Check if an event should be displayed based on the filter. - * - * @param {any} event Event object. - * @return {boolean} Whether it should be displayed. - */ - protected shouldDisplayEvent(event: any): boolean { - if (event.eventtype == 'user' || event.eventtype == 'site') { - // User or site event, display it. - return true; - } - - if (event.eventtype == 'category') { - if (!event.categoryid || !Object.keys(this.categories).length) { - // We can't tell if the course belongs to the category, display them all. - return true; - } - if (event.categoryid == this.filter.course.category) { - // The event is in the same category as the course, display it. - return true; - } - - // Check parent categories. - let category = this.categories[this.filter.course.category]; - while (category) { - if (!category.parent) { - // Category doesn't have parent, stop. - break; - } - - if (event.categoryid == category.parent) { - return true; - } - category = this.categories[category.parent]; - } - - return false; - } - - // Show the event if it is from site home or if it matches the selected course. - return event.courseid === this.siteHomeId || event.courseid == this.filter.course.id; + return this.events.filter((event) => { + return this.calendarHelper.shouldDisplayEvent(event, this.courseId, this.categoryId, this.categories); + }); } /** @@ -297,25 +448,112 @@ export class AddonCalendarListPage implements OnDestroy { }); } + /** + * Merge a period of online events with the offline events of that period. + * + * @param {any[]} onlineEvents Online events. + * @return {any[]} Merged events. + */ + protected mergeEvents(onlineEvents: any[]): any[] { + if (!this.offlineEvents.length && !this.deletedEvents.length) { + // No offline events, nothing to merge. + return onlineEvents; + } + + const start = this.initialTime + (CoreConstants.SECONDS_DAY * this.daysLoaded), + end = start + (CoreConstants.SECONDS_DAY * AddonCalendarProvider.DAYS_INTERVAL) - 1; + let result = onlineEvents; + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + result.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + }); + } + + if (this.offlineEvents.length) { + // Remove the online events that were modified in offline. + result = result.filter((event) => { + const offlineEvent = this.offlineEvents.find((ev) => { + return ev.id == event.id; + }); + + return !offlineEvent; + }); + } + + // Now get the offline events that belong to this period. + const periodOfflineEvents = this.offlineEvents.filter((event) => { + if (this.daysLoaded == 0 && event.timestart < start) { + // Display offline events that are previous to current time to allow editing them. + return true; + } + + return (event.timestart >= start || event.timestart + event.timeduration >= start) && event.timestart <= end; + }); + + // Merge both arrays and sort them. + result = result.concat(periodOfflineEvents); + + return this.sortEvents(result); + } + + /** + * Sort events by timestart. + * + * @param {any[]} events List to sort. + */ + protected sortEvents(events: any[]): any[] { + return events.sort((a, b) => { + if (a.timestart == b.timestart) { + return a.timeduration - b.timeduration; + } + + return a.timestart - b.timestart; + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void, showErrors?: boolean): Promise { + if (this.eventsLoaded) { + return this.refreshEvents(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); + } + + return Promise.resolve(); + } + /** * Refresh the events. * - * @param {any} refresher Refresher. + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. */ - refreshEvents(refresher: any): void { + refreshEvents(sync?: boolean, showErrors?: boolean): Promise { + this.syncIcon = 'spinner'; + const promises = []; promises.push(this.calendarProvider.invalidateEventsList()); + promises.push(this.calendarProvider.invalidateAllowedEventTypes()); if (this.categoriesRetrieved) { promises.push(this.coursesProvider.invalidateCategories(0, true)); this.categoriesRetrieved = false; } - Promise.all(promises).finally(() => { - this.fetchData(true).finally(() => { - refresher.complete(); - }); + return Promise.all(promises).finally(() => { + return this.fetchData(true, sync, showErrors); }); } @@ -359,21 +597,41 @@ export class AddonCalendarListPage implements OnDestroy { * @param {MouseEvent} event Event. */ openCourseFilter(event: MouseEvent): void { - const popover = this.popoverCtrl.create(CoreCoursePickerMenuPopoverComponent, { - courses: this.courses, - courseId: this.filter.course.id - }); - popover.onDidDismiss((course) => { - if (course) { - this.filter.course = course; - this.domUtils.scrollToTop(this.content); + this.coursesHelper.selectCourse(event, this.courses, this.courseId).then((result) => { + if (typeof result.courseId != 'undefined') { + this.courseId = result.courseId > 0 ? result.courseId : undefined; + this.categoryId = result.courseId > 0 ? result.categoryId : undefined; + + // Course viewed has changed, check if the user can create events for this course calendar. + this.calendarHelper.canEditEvents(this.courseId).then((canEdit) => { + this.canCreate = canEdit; + }); this.filteredEvents = this.getFilteredEvents(); + + this.domUtils.scrollToTop(this.content); } }); - popover.present({ - ev: event - }); + } + + /** + * Open page to create/edit an event. + * + * @param {number} [eventId] Event ID to edit. + */ + openEdit(eventId?: number): void { + this.eventId = undefined; + + const params: any = {}; + + if (eventId) { + params.eventId = eventId; + } + if (this.courseId) { + params.courseId = this.courseId; + } + + this.splitviewCtrl.push('AddonCalendarEditEventPage', params); } /** @@ -390,7 +648,31 @@ export class AddonCalendarListPage implements OnDestroy { */ gotoEvent(eventId: number): void { this.eventId = eventId; - this.splitviewCtrl.push('AddonCalendarEventPage', { id: eventId }); + + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + this.splitviewCtrl.push('AddonCalendarEventPage', { + id: eventId + }); + } + } + + /** + * Find an event and mark it as deleted. + * + * @param {number} eventId Event ID. + * @param {boolean} deleted Whether to mark it as deleted or not. + */ + protected markAsDeleted(eventId: number, deleted: boolean): void { + const event = this.onlineEvents.find((event) => { + return event.id == eventId; + }); + + if (event) { + event.deleted = deleted; + } } /** @@ -398,5 +680,13 @@ export class AddonCalendarListPage implements OnDestroy { */ ngOnDestroy(): void { this.obsDefaultTimeChange && this.obsDefaultTimeChange.off(); + this.newEventObserver && this.newEventObserver.off(); + this.discardedObserver && this.discardedObserver.off(); + this.editEventObserver && this.editEventObserver.off(); + this.deleteEventObserver && this.deleteEventObserver.off(); + this.undeleteEventObserver && this.undeleteEventObserver.off(); + this.syncObserver && this.syncObserver.off(); + this.manualSyncObserver && this.manualSyncObserver.off(); + this.onlineObserver && this.onlineObserver.unsubscribe(); } } diff --git a/src/addon/calendar/providers/calendar-offline.ts b/src/addon/calendar/providers/calendar-offline.ts new file mode 100644 index 00000000000..5b0f024362a --- /dev/null +++ b/src/addon/calendar/providers/calendar-offline.ts @@ -0,0 +1,389 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; + +/** + * Service to handle offline calendar events. + */ +@Injectable() +export class AddonCalendarOfflineProvider { + + // Variables for database. + static EVENTS_TABLE = 'addon_calendar_offline_events'; + static DELETED_EVENTS_TABLE = 'addon_calendar_deleted_events'; + + protected siteSchema: CoreSiteSchema = { + name: 'AddonCalendarOfflineProvider', + version: 1, + tables: [ + { + name: AddonCalendarOfflineProvider.EVENTS_TABLE, + columns: [ + { + name: 'id', // Negative for offline entries. + type: 'INTEGER', + primaryKey: true + }, + { + name: 'name', + type: 'TEXT', + notNull: true + }, + { + name: 'timestart', + type: 'INTEGER', + notNull: true + }, + { + name: 'eventtype', + type: 'TEXT', + notNull: true + }, + { + name: 'categoryid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'groupcourseid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'description', + type: 'TEXT', + }, + { + name: 'location', + type: 'TEXT', + }, + { + name: 'duration', + type: 'INTEGER', + }, + { + name: 'timedurationuntil', + type: 'INTEGER', + }, + { + name: 'timedurationminutes', + type: 'INTEGER', + }, + { + name: 'repeat', + type: 'INTEGER', + }, + { + name: 'repeats', + type: 'INTEGER', + }, + { + name: 'repeatid', + type: 'INTEGER', + }, + { + name: 'repeateditall', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + } + ] + }, + { + name: AddonCalendarOfflineProvider.DELETED_EVENTS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'name', // Save the name to be able to notify the user. + type: 'TEXT', + notNull: true + }, + { + name: 'repeat', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER', + } + ] + } + ] + }; + + constructor(private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Delete an offline event. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteEvent(eventId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + id: eventId + }; + + return site.getDb().deleteRecords(AddonCalendarOfflineProvider.EVENTS_TABLE, conditions); + }); + } + + /** + * Get the IDs of all the events created/edited/deleted in offline. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the IDs. + */ + getAllEventsIds(siteId?: string): Promise { + const promises = []; + + promises.push(this.getAllDeletedEventsIds(siteId)); + promises.push(this.getAllEditedEventsIds(siteId)); + + return Promise.all(promises).then((result) => { + return this.utils.mergeArraysWithoutDuplicates(result[0], result[1]); + }); + } + + /** + * Get all the events deleted in offline. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with all the events deleted in offline. + */ + getAllDeletedEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonCalendarOfflineProvider.DELETED_EVENTS_TABLE); + }); + } + + /** + * Get the IDs of all the events deleted in offline. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the IDs of all the events deleted in offline. + */ + getAllDeletedEventsIds(siteId?: string): Promise { + return this.getAllDeletedEvents(siteId).then((events) => { + return events.map((event) => { + return event.id; + }); + }); + } + + /** + * Get all the events created/edited in offline. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with events. + */ + getAllEditedEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonCalendarOfflineProvider.EVENTS_TABLE); + }); + } + + /** + * Get the IDs of all the events created/edited in offline. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with events IDs. + */ + getAllEditedEventsIds(siteId?: string): Promise { + return this.getAllEditedEvents(siteId).then((events) => { + return events.map((event) => { + return event.id; + }); + }); + } + + /** + * Get an event deleted in offline. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the deleted event. + */ + getDeletedEvent(eventId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + id: eventId + }; + + return site.getDb().getRecord(AddonCalendarOfflineProvider.DELETED_EVENTS_TABLE, conditions); + }); + } + + /** + * Get an offline event. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the event. + */ + getEvent(eventId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + id: eventId + }; + + return site.getDb().getRecord(AddonCalendarOfflineProvider.EVENTS_TABLE, conditions); + }); + } + + /** + * Check if there are offline events to send. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if has offline events, false otherwise. + */ + hasEditedEvents(siteId?: string): Promise { + return this.getAllEditedEvents(siteId).then((events) => { + return !!events.length; + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Check whether there's offline data for a site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if has offline data, false otherwise. + */ + hasOfflineData(siteId?: string): Promise { + return this.getAllEventsIds(siteId).then((ids) => { + return ids.length > 0; + }); + } + + /** + * Check if an event is deleted. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether the event is deleted. + */ + isEventDeleted(eventId: number, siteId?: string): Promise { + return this.getDeletedEvent(eventId, siteId).then((event) => { + return !!event; + }).catch(() => { + return false; + }); + } + + /** + * Mark an event as deleted. + * + * @param {number} eventId Event ID to delete. + * @param {number} name Name of the event to delete. + * @param {boolean} [deleteAll] If it's a repeated event. whether to delete all events of the series. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + markDeleted(eventId: number, name: string, deleteAll?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const event = { + id: eventId, + name: name || '', + repeat: deleteAll ? 1 : 0, + timemodified: Date.now() + }; + + return site.getDb().insertRecord(AddonCalendarOfflineProvider.DELETED_EVENTS_TABLE, event); + }); + } + + /** + * Offline version for adding a new discussion to a forum. + * + * @param {number} eventId Event ID. If it's a new event, set it to undefined/null. + * @param {any} data Event data. + * @param {number} [timeCreated] The time the event was created. If not defined, current time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the stored event. + */ + saveEvent(eventId: number, data: any, timeCreated?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + timeCreated = timeCreated || Date.now(); + + const event = { + id: eventId || -timeCreated, + name: data.name, + timestart: data.timestart, + eventtype: data.eventtype, + categoryid: data.categoryid || null, + courseid: data.courseid || null, + groupcourseid: data.groupcourseid || null, + groupid: data.groupid || null, + description: data.description && data.description.text, + location: data.location, + duration: data.duration, + timedurationuntil: data.timedurationuntil, + timedurationminutes: data.timedurationminutes, + repeat: data.repeat ? 1 : 0, + repeats: data.repeats, + repeatid: data.repeatid, + repeateditall: data.repeateditall ? 1 : 0, + timecreated: timeCreated, + userid: site.getUserId() + }; + + return site.getDb().insertRecord(AddonCalendarOfflineProvider.EVENTS_TABLE, event).then(() => { + return event; + }); + }); + } + + /** + * Unmark an event as deleted. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + unmarkDeleted(eventId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + id: eventId + }; + + return site.getDb().deleteRecords(AddonCalendarOfflineProvider.DELETED_EVENTS_TABLE, conditions); + }); + } +} diff --git a/src/addon/calendar/providers/calendar-sync.ts b/src/addon/calendar/providers/calendar-sync.ts new file mode 100644 index 00000000000..bc9d96f3373 --- /dev/null +++ b/src/addon/calendar/providers/calendar-sync.ts @@ -0,0 +1,300 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreAppProvider } from '@providers/app'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonCalendarProvider } from './calendar'; +import { AddonCalendarOfflineProvider } from './calendar-offline'; +import { AddonCalendarHelperProvider } from './helper'; + +/** + * Service to sync calendar. + */ +@Injectable() +export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_calendar_autom_synced'; + static MANUAL_SYNCED = 'addon_calendar_manual_synced'; + static SYNC_ID = 'calendar'; + + constructor(translate: TranslateService, + appProvider: CoreAppProvider, + courseProvider: CoreCourseProvider, + private eventsProvider: CoreEventsProvider, + loggerProvider: CoreLoggerProvider, + sitesProvider: CoreSitesProvider, + syncProvider: CoreSyncProvider, + textUtils: CoreTextUtilsProvider, + timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private calendarHelper: AddonCalendarHelperProvider) { + + super('AddonCalendarSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, + timeUtils); + } + + /** + * Try to synchronize all events in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllEvents(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this), [force], siteId); + } + + /** + * Sync all events on a site. + * + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllEventsFunc(siteId: string, force?: boolean): Promise { + + const promise = force ? this.syncEvents(siteId) : this.syncEventsIfNeeded(siteId); + + return promise.then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonCalendarSyncProvider.AUTO_SYNCED, { + warnings: result.warnings, + events: result.events, + deleted: result.deleted + }, siteId); + } + }); + } + + /** + * Sync a site events only if a certain time has passed since the last time. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the events are synced or if it doesn't need to be synced. + */ + syncEventsIfNeeded(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.isSyncNeeded(AddonCalendarSyncProvider.SYNC_ID, siteId).then((needed) => { + if (needed) { + return this.syncEvents(siteId); + } + }); + } + + /** + * Synchronize all offline events of a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncEvents(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.isSyncing(AddonCalendarSyncProvider.SYNC_ID, siteId)) { + // There's already a sync ongoing for this site, return the promise. + return this.getOngoingSync(AddonCalendarSyncProvider.SYNC_ID, siteId); + } + + this.logger.debug('Try to sync calendar events for site ' + siteId); + + const result = { + warnings: [], + events: [], + deleted: [], + toinvalidate: [], + updated: false + }; + let offlineEventIds: number[]; + + // Get offline events. + const syncPromise = this.calendarOffline.getAllEventsIds(siteId).catch(() => { + // No offline data found, return empty list. + return []; + }).then((eventIds) => { + offlineEventIds = eventIds; + + if (!eventIds.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = []; + + offlineEventIds.forEach((eventId) => { + promises.push(this.syncOfflineEvent(eventId, result, siteId)); + }); + + return this.utils.allPromises(promises); + }).then(() => { + if (result.updated) { + + // Data has been sent to server. Now invalidate the WS calls. + const promises = [ + this.calendarProvider.invalidateEventsList(siteId), + this.calendarHelper.refreshAfterChangeEvents(result.toinvalidate, siteId) + ]; + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(AddonCalendarSyncProvider.SYNC_ID, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the result. + return result; + }); + + return this.addOngoingSync(AddonCalendarSyncProvider.SYNC_ID, syncPromise, siteId); + } + + /** + * Synchronize an offline event. + * + * @param {number} eventId The event ID to sync. + * @param {any} result Object where to store the result of the sync. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + protected syncOfflineEvent(eventId: number, result: any, siteId?: string): Promise { + + // Verify that event isn't blocked. + if (this.syncProvider.isBlocked(AddonCalendarProvider.COMPONENT, eventId, siteId)) { + this.logger.debug('Cannot sync event ' + eventId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', + {$a: this.translate.instant('addon.calendar.calendarevent')})); + } + + // First of all, check if the event has been deleted. + return this.calendarOffline.getDeletedEvent(eventId, siteId).then((data) => { + // Delete the event. + return this.calendarProvider.deleteEventOnline(data.id, data.repeat, siteId).then(() => { + result.updated = true; + result.deleted.push(eventId); + + // Event sent, delete the offline data. + const promises = []; + + promises.push(this.calendarOffline.unmarkDeleted(eventId, siteId)); + promises.push(this.calendarOffline.deleteEvent(eventId, siteId).catch(() => { + // Ignore errors, maybe there was no edit data. + })); + + // We need the event data to invalidate it. Get it from local DB. + promises.push(this.calendarProvider.getEventFromLocalDb(eventId, siteId).then((event) => { + result.toinvalidate.push({ + event: event, + repeated: data.repeat ? event.eventcount : 1 + }); + }).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises); + }).catch((error) => { + + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the event cannot be created. Delete it. + result.updated = true; + + const promises = []; + + promises.push(this.calendarOffline.unmarkDeleted(eventId, siteId)); + promises.push(this.calendarOffline.deleteEvent(eventId, siteId).catch(() => { + // Ignore errors, maybe there was no edit data. + })); + + return Promise.all(promises).then(() => { + // Event deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.translate.instant('addon.calendar.calendarevent'), + name: data.name, + error: this.textUtils.getErrorMessageFromError(error) + })); + }); + } + + // Local error, reject. + return Promise.reject(error); + }); + }, () => { + + // Not deleted. Now get the event data. + return this.calendarOffline.getEvent(eventId, siteId).then((event) => { + // Try to send the data. + const data = this.utils.clone(event); // Clone the object because it will be modified in the submit function. + + data.description = { + text: data.description, + format: 1 + }; + + return this.calendarProvider.submitEventOnline(eventId > 0 ? eventId : undefined, data, siteId).then((newEvent) => { + result.updated = true; + result.events.push(newEvent); + + // Add data to invalidate. + const numberOfRepetitions = data.repeat ? data.repeats : + (data.repeateditall && newEvent.repeatid ? newEvent.eventcount : 1); + + result.toinvalidate.push({ + event: newEvent, + repeated: numberOfRepetitions + }); + + // Event sent, delete the offline data. + return this.calendarOffline.deleteEvent(event.id, siteId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the event cannot be created. Delete it. + result.updated = true; + + return this.calendarOffline.deleteEvent(event.id, siteId).then(() => { + // Event deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.translate.instant('addon.calendar.calendarevent'), + name: event.name, + error: this.textUtils.getErrorMessageFromError(error) + })); + }); + } + + // Local error, reject. + return Promise.reject(error); + }); + }); + }); + } +} diff --git a/src/addon/calendar/providers/calendar.ts b/src/addon/calendar/providers/calendar.ts index 40dcd2208c4..892cb35cd91 100644 --- a/src/addon/calendar/providers/calendar.ts +++ b/src/addon/calendar/providers/calendar.ts @@ -15,15 +15,23 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; -import { CoreSite } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreAppProvider } from '@providers/app'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreGroupsProvider } from '@providers/groups'; import { CoreConstants } from '@core/constants'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreConfigProvider } from '@providers/config'; import { ILocalNotification } from '@ionic-native/local-notifications'; import { SQLiteDB } from '@classes/sqlitedb'; +import { AddonCalendarOfflineProvider } from './calendar-offline'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { TranslateService } from '@ngx-translate/core'; +import * as moment from 'moment'; /** * Service to handle calendar events. @@ -35,14 +43,61 @@ export class AddonCalendarProvider { static DEFAULT_NOTIFICATION_TIME_CHANGED = 'AddonCalendarDefaultNotificationTimeChangedEvent'; static DEFAULT_NOTIFICATION_TIME_SETTING = 'mmaCalendarDefaultNotifTime'; static DEFAULT_NOTIFICATION_TIME = 60; + static STARTING_WEEK_DAY = 'addon_calendar_starting_week_day'; + static NEW_EVENT_EVENT = 'addon_calendar_new_event'; + static NEW_EVENT_DISCARDED_EVENT = 'addon_calendar_new_event_discarded'; + static EDIT_EVENT_EVENT = 'addon_calendar_edit_event'; + static DELETED_EVENT_EVENT = 'addon_calendar_deleted_event'; + static UNDELETED_EVENT_EVENT = 'addon_calendar_undeleted_event'; + static TYPE_CATEGORY = 'category'; + static TYPE_COURSE = 'course'; + static TYPE_GROUP = 'group'; + static TYPE_SITE = 'site'; + static TYPE_USER = 'user'; + + static CALENDAR_TF_24 = '%H:%M'; // Calendar time in 24 hours format. + static CALENDAR_TF_12 = '%I:%M %p'; // Calendar time in 12 hours format. + protected ROOT_CACHE_KEY = 'mmaCalendar:'; + protected weekDays = [ + { + shortname: 'addon.calendar.sun', + fullname: 'addon.calendar.sunday' + }, + { + shortname: 'addon.calendar.mon', + fullname: 'addon.calendar.monday' + }, + { + shortname: 'addon.calendar.tue', + fullname: 'addon.calendar.tuesday' + }, + { + shortname: 'addon.calendar.wed', + fullname: 'addon.calendar.wednesday' + }, + { + shortname: 'addon.calendar.thu', + fullname: 'addon.calendar.thursday' + }, + { + shortname: 'addon.calendar.fri', + fullname: 'addon.calendar.friday' + }, + { + shortname: 'addon.calendar.sat', + fullname: 'addon.calendar.saturday' + } + ]; + // Variables for database. - static EVENTS_TABLE = 'addon_calendar_events_2'; + static EVENTS_TABLE = 'addon_calendar_events_3'; static REMINDERS_TABLE = 'addon_calendar_reminders'; protected siteSchema: CoreSiteSchema = { name: 'AddonCalendarProvider', - version: 2, + version: 3, + canBeCleared: [ AddonCalendarProvider.EVENTS_TABLE ], tables: [ { name: AddonCalendarProvider.EVENTS_TABLE, @@ -124,6 +179,82 @@ export class AddonCalendarProvider { { name: 'subscriptionid', type: 'INTEGER' + }, + { + name: 'location', + type: 'TEXT' + }, + { + name: 'eventcount', + type: 'INTEGER' + }, + { + name: 'timesort', + type: 'INTEGER' + }, + { + name: 'category', + type: 'TEXT' + }, + { + name: 'course', + type: 'TEXT' + }, + { + name: 'subscription', + type: 'TEXT' + }, + { + name: 'canedit', + type: 'INTEGER' + }, + { + name: 'candelete', + type: 'INTEGER' + }, + { + name: 'deleteurl', + type: 'TEXT' + }, + { + name: 'editurl', + type: 'TEXT' + }, + { + name: 'viewurl', + type: 'TEXT' + }, + { + name: 'formattedtime', + type: 'TEXT' + }, + { + name: 'isactionevent', + type: 'INTEGER' + }, + { + name: 'url', + type: 'TEXT' + }, + { + name: 'islastday', + type: 'INTEGER' + }, + { + name: 'popupname', + type: 'TEXT' + }, + { + name: 'mindaytimestamp', + type: 'INTEGER' + }, + { + name: 'maxdaytimestamp', + type: 'INTEGER' + }, + { + name: 'draggable', + type: 'INTEGER' } ] }, @@ -150,67 +281,142 @@ export class AddonCalendarProvider { } ], migrate(db: SQLiteDB, oldVersion: number, siteId: string): Promise | void { - if (oldVersion < 2) { + if (oldVersion < 3) { const newTable = AddonCalendarProvider.EVENTS_TABLE; - const oldTable = 'addon_calendar_events'; + let oldTable = 'addon_calendar_events_2'; - return db.tableExists(oldTable).then(() => { - return db.getAllRecords(oldTable).then((events) => { - const now = Math.round(Date.now() / 1000); + return db.tableExists(oldTable).catch(() => { + // The v2 table doesn't exist, try with v1. + oldTable = 'addon_calendar_events'; - return Promise.all(events.map((event) => { - if (event.notificationtime == 0) { - // No reminders. - return Promise.resolve(); - } - - let time; - - if (event.notificationtime == -1) { - time = -1; - } else { - time = event.timestart - event.notificationtime * 60; - - if (time < now) { - // Old reminder, just not add this. - return Promise.resolve(); - } - } + return db.tableExists(oldTable); + }).then(() => { + // Move the records from the old table. + // Move the records from the old table. + return db.getAllRecords(oldTable).then((events) => { + const promises = []; - const reminder = { - eventid: event.id, - time: time - }; - - // Cancel old notification. - this.localNotificationsProvider.cancel(event.id, AddonCalendarProvider.COMPONENT, siteId); - - return db.insertRecord(AddonCalendarProvider.REMINDERS_TABLE, reminder); - })).then(() => { - // Move the records from the old table. - return db.insertRecordsFrom(newTable, oldTable, undefined, 'id, name, description, format, eventtype,\ - courseid, timestart, timeduration, categoryid, groupid, userid, instance, modulename, timemodified,\ - repeatid, visible, uuid, sequence, subscriptionid'); - }).then(() => { - return db.dropTable(oldTable); + events.forEach((event) => { + promises.push(db.insertRecord(newTable, event)); }); + + return Promise.all(promises); }); + }).then(() => { + return db.dropTable(oldTable); }).catch(() => { // Old table does not exist, ignore. }); } - } + }, }; protected logger; - constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private groupsProvider: CoreGroupsProvider, - private coursesProvider: CoreCoursesProvider, private timeUtils: CoreTimeUtilsProvider, - private localNotificationsProvider: CoreLocalNotificationsProvider, private configProvider: CoreConfigProvider) { + constructor(logger: CoreLoggerProvider, + private sitesProvider: CoreSitesProvider, + private groupsProvider: CoreGroupsProvider, + private coursesProvider: CoreCoursesProvider, + private textUtils: CoreTextUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, + private urlUtils: CoreUrlUtilsProvider, + private localNotificationsProvider: CoreLocalNotificationsProvider, + private configProvider: CoreConfigProvider, + private utils: CoreUtilsProvider, + private calendarOffline: AddonCalendarOfflineProvider, + private appProvider: CoreAppProvider, + private translate: TranslateService, + private userProvider: CoreUserProvider) { + this.logger = logger.getInstance('AddonCalendarProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); } + /** + * Check if a certain site allows deleting events. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if can delete. + * @since 3.3 + */ + canDeleteEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.canDeleteEventsInSite(site); + }).catch(() => { + return false; + }); + } + + /** + * Check if a certain site allows deleting events. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether events can be deleted. + * @since 3.3 + */ + canDeleteEventsInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_calendar_delete_calendar_events'); + } + + /** + * Check if a certain site allows creating and editing events. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if can create/edit. + * @since 3.7.1 + */ + canEditEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.canEditEventsInSite(site); + }).catch(() => { + return false; + }); + } + + /** + * Check if a certain site allows creating and editing events. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether events can be created and edited. + * @since 3.7.1 + */ + canEditEventsInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + // The WS to create/edit events requires a fix that was integrated in 3.7.1. + return site.isVersionGreaterEqualThan('3.7.1'); + } + + /** + * Check if a certain site allows viewing events in monthly view. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if monthly view is supported. + * @since 3.4 + */ + canViewMonth(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.canViewMonthInSite(site); + }).catch(() => { + return false; + }); + } + + /** + * Check if a certain site allows viewing events in monthly view. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether monthly view is supported. + * @since 3.4 + */ + canViewMonthInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_calendar_get_calendar_monthly_view'); + } + /** * Removes expired events from local DB. * @@ -219,23 +425,97 @@ export class AddonCalendarProvider { */ cleanExpiredEvents(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { + if (this.canViewMonthInSite(site)) { + // Site supports monthly view, don't clean expired events because user can see past events. + return; + } + return site.getDb().getRecordsSelect(AddonCalendarProvider.EVENTS_TABLE, 'timestart + timeduration < ?', [this.timeUtils.timestamp()]).then((events) => { return Promise.all(events.map((event) => { - return this.deleteEvent(event.id, siteId); + return this.deleteLocalEvent(event.id, siteId); })); }); }); } /** - * Delete event cancelling all the reminders and notifications. + * Delete an event. + * + * @param {number} eventId Event ID to delete. + * @param {string} name Name of the event to delete. + * @param {boolean} [deleteAll] If it's a repeated event. whether to delete all events of the series. + * @param {boolean} [forceOffline] True to always save it in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteEvent(eventId: number, name: string, deleteAll?: boolean, forceOffline?: boolean, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Function to store the submission to be synchronized later. + const storeOffline = (): Promise => { + return this.calendarOffline.markDeleted(eventId, name, deleteAll, siteId).then(() => { + return false; + }); + }; + + if (forceOffline || !this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If the event is already stored, discard it first. + return this.calendarOffline.unmarkDeleted(eventId, siteId).then(() => { + return this.deleteEventOnline(eventId, deleteAll, siteId).then(() => { + return true; + }).catch((error) => { + if (error && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Delete an event. It will fail if offline or cannot connect. + * + * @param {number} eventId Event ID to delete. + * @param {boolean} [deleteAll] If it's a repeated event. whether to delete all events of the series. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteEventOnline(eventId: number, deleteAll?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + const params = { + events: [ + { + eventid: eventId, + repeat: deleteAll ? 1 : 0 + } + ] + }, + preSets = { + responseExpected: false + }; + + return site.write('core_calendar_delete_calendar_events', params, preSets); + }); + } + + /** + * Delete a locally stored event cancelling all the reminders and notifications. * * @param {number} eventId Event ID. * @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site. * @return {Promise} Resolved when done. */ - protected deleteEvent(eventId: number, siteId?: string): Promise { + protected deleteLocalEvent(eventId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { siteId = site.getId(); @@ -255,6 +535,131 @@ export class AddonCalendarProvider { }); } + /** + * Check if event ends the same day or not. + * + * @param {any} event Event info. + * @return {boolean} If the . + */ + endsSameDay(event: any): boolean { + if (!event.timeduration) { + // No duration. + return true; + } + + // Check if day has changed. + return moment(event.timestart * 1000).isSame((event.timestart + event.timeduration) * 1000, 'day'); + } + + /** + * Format event time. Similar to calendar_format_event_time. + * + * @param {any} event Event to format. + * @param {string} format Calendar time format (from getCalendarTimeFormat). + * @param {boolean} [useCommonWords=true] Whether to use common words like "Today", "Yesterday", etc. + * @param {number} [seenDay] Timestamp of day currently seen. If set, the function will not add links to this day. + * @param {number} [showTime=0] Determine the show time GMT timestamp. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the formatted event time. + */ + formatEventTime(event: any, format: string, useCommonWords: boolean = true, seenDay?: number, showTime: number = 0, + siteId?: string): Promise { + + const start = event.timestart * 1000, + end = (event.timestart + event.timeduration) * 1000; + let time; + + if (event.timeduration) { + + if (moment(start).isSame(end, 'day')) { + // Event starts and ends the same day. + if (event.timeduration == CoreConstants.SECONDS_DAY) { + time = this.translate.instant('addon.calendar.allday'); + } else { + time = this.timeUtils.userDate(start, format) + ' » ' + + this.timeUtils.userDate(end, format); + } + + } else { + // Event lasts more than one day. + const timeStart = this.timeUtils.userDate(start, format), + timeEnd = this.timeUtils.userDate(end, format), + promises = []; + + // Don't use common words when the event lasts more than one day. + let dayStart = this.getDayRepresentation(start, false) + ', ', + dayEnd = this.getDayRepresentation(end, false) + ', '; + + // Add links to the days if needed. + if (dayStart && (!seenDay || !moment(seenDay).isSame(start, 'day'))) { + promises.push(this.getViewUrl('day', event.timestart, undefined, siteId).then((url) => { + dayStart = this.urlUtils.buildLink(url, dayStart); + })); + } + if (dayEnd && (!seenDay || !moment(seenDay).isSame(end, 'day'))) { + promises.push(this.getViewUrl('day', end / 1000, undefined, siteId).then((url) => { + dayEnd = this.urlUtils.buildLink(url, dayEnd); + })); + } + + return Promise.all(promises).then(() => { + return dayStart + timeStart + ' » ' + dayEnd + timeEnd; + }); + } + } else { + // There is no time duration. + time = this.timeUtils.userDate(start, format); + } + + if (!showTime) { + // Display day + time. + if (seenDay && moment(seenDay).isSame(start, 'day')) { + // This day is currently being displayed, don't add an link. + return Promise.resolve(this.getDayRepresentation(start, useCommonWords) + ', ' + time); + } else { + // Add link to view the day. + return this.getViewUrl('day', event.timestart, undefined, siteId).then((url) => { + return this.urlUtils.buildLink(url, this.getDayRepresentation(start, useCommonWords)) + ', ' + time; + }); + } + } else { + return Promise.resolve(time); + } + } + + /** + * Get access information for a calendar (either course calendar or site calendar). + * + * @param {number} [courseId] Course ID. If not defined, site calendar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with object with access information. + * @since 3.7 + */ + getAccessInformation(courseId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = {}, + preSets = { + cacheKey: this.getAccessInformationCacheKey(courseId) + }; + + if (courseId) { + params.courseid = courseId; + } + + return site.read('core_calendar_get_calendar_access_information', params, preSets); + }); + } + + /** + * Get cache key for calendar access information WS calls. + * + * @param {number} [courseId] Course ID. + * @return {string} Cache key. + */ + protected getAccessInformationCacheKey(courseId?: number): string { + return this.ROOT_CACHE_KEY + 'accessInformation:' + (courseId || 0); + } + /** * Get all calendar events from local Db. * @@ -267,6 +672,128 @@ export class AddonCalendarProvider { }); } + /** + * Get the type of events a user can create (either course calendar or site calendar). + * + * @param {number} [courseId] Course ID. If not defined, site calendar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with an object indicating the types. + * @since 3.7 + */ + getAllowedEventTypes(courseId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = {}, + preSets = { + cacheKey: this.getAllowedEventTypesCacheKey(courseId) + }; + + if (courseId) { + params.courseid = courseId; + } + + return site.read('core_calendar_get_allowed_event_types', params, preSets).then((response) => { + // Convert the array to an object. + const result = {}; + + if (response.allowedeventtypes) { + response.allowedeventtypes.map((type) => { + result[type] = true; + }); + } + + return result; + }); + }); + } + + /** + * Get cache key for calendar allowed event types WS calls. + * + * @param {number} [courseId] Course ID. + * @return {string} Cache key. + */ + protected getAllowedEventTypesCacheKey(courseId?: number): string { + return this.ROOT_CACHE_KEY + 'allowedEventTypes:' + (courseId || 0); + } + + /** + * Get the "look ahead" for a certain user. + * + * @param {string} [siteId] ID of the site. If not defined, use current site. + * @return {Promise} Promise resolved with the look ahead (number of days). + */ + getCalendarLookAhead(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.userProvider.getUserPreference('calendar_lookahead').catch((error) => { + // Ignore errors. + }).then((value): any => { + if (value != null) { + return value; + } + + return site.getStoredConfig('calendar_lookahead'); + }); + }); + } + + /** + * Get the time format to use in calendar. + * + * @param {string} [siteId] ID of the site. If not defined, use current site. + * @return {Promise} Promise resolved with the format. + */ + getCalendarTimeFormat(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.userProvider.getUserPreference('calendar_timeformat').catch((error) => { + // Ignore errors. + }).then((format) => { + + if (!format || format === '0') { + format = site.getStoredConfig('calendar_site_timeformat'); + } + + if (format === AddonCalendarProvider.CALENDAR_TF_12) { + format = this.translate.instant('core.strftimetime12'); + } else if (format === AddonCalendarProvider.CALENDAR_TF_24) { + format = this.translate.instant('core.strftimetime24'); + } + + return format && format !== '0' ? format : this.translate.instant('core.strftimetime'); + }); + }); + } + + /** + * Return the representation day. Equivalent to Moodle's calendar_day_representation. + * + * @param {number} time Timestamp to get the day from. + * @param {boolean} [useCommonWords=true] Whether to use common words like "Today", "Yesterday", etc. + * @return {string} The formatted date/time. + */ + getDayRepresentation(time: number, useCommonWords: boolean = true): string { + + if (!useCommonWords) { + // We don't want words, just a date. + return this.timeUtils.userDate(time, 'core.strftimedayshort'); + } + + const date = moment(time), + today = moment(); + + if (date.isSame(today, 'day')) { + return this.translate.instant('addon.calendar.today'); + + } else if (date.isSame(today.clone().subtract(1, 'days'), 'day')) { + return this.translate.instant('addon.calendar.yesterday'); + + } else if (date.isSame(today.clone().add(1, 'days'), 'day')) { + return this.translate.instant('addon.calendar.tomorrow'); + + } else { + return this.timeUtils.userDate(time, 'core.strftimedayshort'); + } + } + /** * Get the configured default notification time. * @@ -339,6 +866,10 @@ export class AddonCalendarProvider { return site.read('core_calendar_get_calendar_event_by_id', data, preSets).then((response) => { return response.event; + }).catch((error) => { + return this.getEventFromLocalDb(id).catch(() => { + return Promise.reject(error); + }); }); }); } @@ -362,46 +893,158 @@ export class AddonCalendarProvider { */ getEventFromLocalDb(id: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.getDb().getRecord(AddonCalendarProvider.EVENTS_TABLE, { id: id }); + return site.getDb().getRecord(AddonCalendarProvider.EVENTS_TABLE, { id: id }).then((event) => { + if (this.isGetEventByIdAvailableInSite(site)) { + // Calculate data to match the new WS. + event.descriptionformat = event.format; + event.iscourseevent = event.eventtype == AddonCalendarProvider.TYPE_COURSE; + event.iscategoryevent = event.eventtype == AddonCalendarProvider.TYPE_CATEGORY; + event.normalisedeventtype = this.getEventType(event); + event.category = this.textUtils.parseJSON(event.category, null); + event.course = this.textUtils.parseJSON(event.course, null); + event.subscription = this.textUtils.parseJSON(event.subscription, null); + } + + return event; + }); + }); + } + + /** + * Adds an event reminder and schedule a new notification. + * + * @param {any} event Event to update its notification time. + * @param {number} time New notification setting timestamp. + * @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site. + * @return {Promise} Promise resolved when the notification is updated. + */ + addEventReminder(event: any, time: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const reminder = { + eventid: event.id, + time: time + }; + + return site.getDb().insertRecord(AddonCalendarProvider.REMINDERS_TABLE, reminder).then((reminderId) => { + return this.scheduleEventNotification(event, reminderId, time, site.getId()); + }); + }); + } + + /** + * Return the normalised event type. + * Activity events are normalised to be course events. + * + * @param {any} event The event to get its type. + * @return {string} Event type. + */ + getEventType(event: any): string { + if (event.modulename) { + return 'course'; + } + + return event.eventtype; + } + + /** + * Remove an event reminder and cancel the notification. + * + * @param {number} id Reminder ID. + * @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site. + * @return {Promise} Promise resolved when the notification is updated. + */ + deleteEventReminder(id: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + if (this.localNotificationsProvider.isAvailable()) { + this.localNotificationsProvider.cancel(id, AddonCalendarProvider.COMPONENT, site.getId()); + } + + return site.getDb().deleteRecords(AddonCalendarProvider.REMINDERS_TABLE, {id: id}); + }); + } + + /** + * Get calendar events for a certain day. + * + * @param {number} year Year to get. + * @param {number} month Month to get. + * @param {number} day Day to get. + * @param {number} [courseId] Course to get. + * @param {number} [categoryId] Category to get. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the response. + */ + getDayEvents(year: number, month: number, day: number, courseId?: number, categoryId?: number, ignoreCache?: boolean, + siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const data: any = { + year: year, + month: month, + day: day + }; + + if (courseId) { + data.courseid = courseId; + } + if (categoryId) { + data.categoryid = categoryId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDayEventsCacheKey(year, month, day, courseId, categoryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_calendar_get_calendar_day_view', data, preSets).then((response) => { + this.storeEventsInLocalDB(response.events, siteId); + + return response; + }); }); } /** - * Adds an event reminder and schedule a new notification. + * Get prefix cache key for day events WS calls. * - * @param {any} event Event to update its notification time. - * @param {number} time New notification setting timestamp. - * @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site. - * @return {Promise} Promise resolved when the notification is updated. + * @return {string} Prefix Cache key. */ - addEventReminder(event: any, time: number, siteId?: string): Promise { - return this.sitesProvider.getSite(siteId).then((site) => { - const reminder = { - eventid: event.id, - time: time - }; - - return site.getDb().insertRecord(AddonCalendarProvider.REMINDERS_TABLE, reminder).then((reminderId) => { - return this.scheduleEventNotification(event, reminderId, time, site.getId()); - }); - }); + protected getDayEventsPrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'day:'; } /** - * Remove an event reminder and cancel the notification. + * Get prefix cache key for a certain day for day events WS calls. * - * @param {number} id Reminder ID. - * @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site. - * @return {Promise} Promise resolved when the notification is updated. + * @param {number} year Year to get. + * @param {number} month Month to get. + * @param {number} day Day to get. + * @return {string} Prefix Cache key. */ - deleteEventReminder(id: number, siteId?: string): Promise { - return this.sitesProvider.getSite(siteId).then((site) => { - if (this.localNotificationsProvider.isAvailable()) { - this.localNotificationsProvider.cancel(id, AddonCalendarProvider.COMPONENT, site.getId()); - } + protected getDayEventsDayPrefixCacheKey(year: number, month: number, day: number): string { + return this.getDayEventsPrefixCacheKey() + year + ':' + month + ':' + day + ':'; + } - return site.getDb().deleteRecords(AddonCalendarProvider.REMINDERS_TABLE, {id: id}); - }); + /** + * Get cache key for day events WS calls. + * + * @param {number} year Year to get. + * @param {number} month Month to get. + * @param {number} day Day to get. + * @param {number} [courseId] Course to get. + * @param {number} [categoryId] Category to get. + * @return {string} Cache key. + */ + protected getDayEventsCacheKey(year: number, month: number, day: number, courseId?: number, categoryId?: number): string { + return this.getDayEventsDayPrefixCacheKey(year, month, day) + (courseId ? courseId : '') + ':' + + (categoryId ? categoryId : ''); } /** @@ -421,16 +1064,20 @@ export class AddonCalendarProvider { * Get the events in a certain period. The period is calculated like this: * start time: now + daysToStart * end time: start time + daysInterval - * E.g. using provider.getEventsList(30, 30) is going to get the events starting after 30 days from now + * E.g. using provider.getEventsList(undefined, 30, 30) is going to get the events starting after 30 days from now * and ending before 60 days from now. * - * @param {number} [daysToStart=0] Number of days from now to start getting events. + * @param {number} [initialTime] Timestamp when the first fetch was done. If not defined, current time. + * @param {number} [daysToStart=0] Number of days from now to start getting events. * @param {number} [daysInterval=30] Number of days between timestart and timeend. * @param {string} [siteId] Site to get the events from. If not defined, use current site. * @return {Promise} Promise to be resolved when the participants are retrieved. */ - getEventsList(daysToStart: number = 0, daysInterval: number = AddonCalendarProvider.DAYS_INTERVAL, siteId?: string) - : Promise { + getEventsList(initialTime?: number, daysToStart: number = 0, daysInterval: number = AddonCalendarProvider.DAYS_INTERVAL, + siteId?: string): Promise { + + initialTime = initialTime || this.timeUtils.timestamp(); + return this.sitesProvider.getSite(siteId).then((site) => { siteId = site.getId(); const promises = []; @@ -445,9 +1092,8 @@ export class AddonCalendarProvider { })); return Promise.all(promises).then(() => { - const now = this.timeUtils.timestamp(), - start = now + (CoreConstants.SECONDS_DAY * daysToStart), - end = start + (CoreConstants.SECONDS_DAY * daysInterval), + const start = initialTime + (CoreConstants.SECONDS_DAY * daysToStart), + end = start + (CoreConstants.SECONDS_DAY * daysInterval) - 1, data = { options: { userevents: 1, @@ -472,11 +1118,15 @@ export class AddonCalendarProvider { const preSets = { cacheKey: this.getEventsListCacheKey(daysToStart, daysInterval), getCacheUsingCacheKey: true, + uniqueCacheKey: true, updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_calendar_get_calendar_events', data, preSets).then((response) => { - this.storeEventsInLocalDB(response.events, siteId); + if (!this.canViewMonthInSite(site)) { + // Store events only in 3.1-3.3. In 3.4+ we'll use the new WS that return more info. + this.storeEventsInLocalDB(response.events, siteId); + } return response.events; }); @@ -504,6 +1154,262 @@ export class AddonCalendarProvider { return this.getEventsListPrefixCacheKey() + daysToStart + ':' + daysInterval; } + /** + * Get calendar events from local Db that have the same repeatid. + * + * @param {number} [repeatId] Repeat Id of the event. + * @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site. + * @return {Promise} Promise resolved with all the events. + */ + getLocalEventsByRepeatIdFromLocalDb(repeatId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonCalendarProvider.EVENTS_TABLE, {repeatid: repeatId}); + }); + } + /** + * Get monthly calendar events. + * + * @param {number} year Year to get. + * @param {number} month Month to get. + * @param {number} [courseId] Course to get. + * @param {number} [categoryId] Category to get. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the response. + */ + getMonthlyEvents(year: number, month: number, courseId?: number, categoryId?: number, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const data: any = { + year: year, + month: month + }; + + // This parameter requires Moodle 3.5. + if (site.isVersionGreaterEqualThan('3.5')) { + // Set mini to 1 to prevent returning the course selector HTML. + data.mini = 1; + } + + if (courseId) { + data.courseid = courseId; + } + if (categoryId) { + data.categoryid = categoryId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getMonthlyEventsCacheKey(year, month, courseId, categoryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_calendar_get_calendar_monthly_view', data, preSets).then((response) => { + response.weeks.forEach((week) => { + week.days.forEach((day) => { + this.storeEventsInLocalDB(day.events, siteId); + }); + }); + + // Store starting week day preference, we need it in offline to show months that are not in cache. + if (this.appProvider.isOnline()) { + this.configProvider.set(AddonCalendarProvider.STARTING_WEEK_DAY, response.daynames[0].dayno); + } + + return response; + }); + }); + } + + /** + * Get prefix cache key for monthly events WS calls. + * + * @return {string} Prefix Cache key. + */ + protected getMonthlyEventsPrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'monthly:'; + } + + /** + * Get prefix cache key for a certain month for monthly events WS calls. + * + * @param {number} year Year to get. + * @param {number} month Month to get. + * @return {string} Prefix Cache key. + */ + protected getMonthlyEventsMonthPrefixCacheKey(year: number, month: number): string { + return this.getMonthlyEventsPrefixCacheKey() + year + ':' + month + ':'; + } + + /** + * Get cache key for monthly events WS calls. + * + * @param {number} year Year to get. + * @param {number} month Month to get. + * @param {number} [courseId] Course to get. + * @param {number} [categoryId] Category to get. + * @return {string} Cache key. + */ + protected getMonthlyEventsCacheKey(year: number, month: number, courseId?: number, categoryId?: number): string { + return this.getMonthlyEventsMonthPrefixCacheKey(year, month) + (courseId ? courseId : '') + ':' + + (categoryId ? categoryId : ''); + } + + /** + * Get upcoming calendar events. + * + * @param {number} [courseId] Course to get. + * @param {number} [categoryId] Category to get. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the response. + */ + getUpcomingEvents(courseId?: number, categoryId?: number, ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const data: any = {}; + + if (courseId) { + data.courseid = courseId; + } + if (categoryId) { + data.categoryid = categoryId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUpcomingEventsCacheKey(courseId, categoryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_calendar_get_calendar_upcoming_view', data, preSets).then((response) => { + this.storeEventsInLocalDB(response.events, siteId); + + return response; + }); + }); + } + + /** + * Get prefix cache key for upcoming events WS calls. + * + * @return {string} Prefix Cache key. + */ + protected getUpcomingEventsPrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'upcoming:'; + } + + /** + * Get cache key for upcoming events WS calls. + * + * @param {number} [courseId] Course to get. + * @param {number} [categoryId] Category to get. + * @return {string} Cache key. + */ + protected getUpcomingEventsCacheKey(courseId?: number, categoryId?: number): string { + return this.getUpcomingEventsPrefixCacheKey() + (courseId ? courseId : '') + ':' + (categoryId ? categoryId : ''); + } + + /** + * Get URL to view a calendar. + * + * @param {string} view The view to load: 'month', 'day', 'upcoming', etc. + * @param {number} [time] Time to load. If not defined, current time. + * @param {string} [courseId] Course to load. If not defined, all courses. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the URL.x + */ + getViewUrl(view: string, time?: number, courseId?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let url = this.textUtils.concatenatePaths(site.getURL(), 'calendar/view.php?view=' + view); + + if (time) { + url += '&time=' + time; + } + + if (courseId) { + url += '&course=' + courseId; + } + + return url; + }); + } + + /** + * Get the week days, already ordered according to a specified starting day. + * + * @param {number} [startingDay=0] Starting day. 0=Sunday, 1=Monday, ... + * @return {any[]} Week days. + */ + getWeekDays(startingDay?: number): any[] { + startingDay = startingDay || 0; + + return this.weekDays.slice(startingDay).concat(this.weekDays.slice(0, startingDay)); + } + + /** + * Invalidates access information. + * + * @param {number} [courseId] Course ID. If not defined, site calendar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAccessInformation(courseId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(courseId)); + }); + } + + /** + * Invalidates allowed event types. + * + * @param {number} [courseId] Course ID. If not defined, site calendar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllowedEventTypes(courseId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAllowedEventTypesCacheKey(courseId)); + }); + } + + /** + * Invalidates day events for all days. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllDayEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getDayEventsPrefixCacheKey()); + }); + } + + /** + * Invalidates day events for a certain day. + * + * @param {number} year Year. + * @param {number} month Month. + * @param {number} day Day. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateDayEvents(year: number, month: number, day: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getDayEventsDayPrefixCacheKey(year, month, day)); + }); + } + /** * Invalidates events list and all the single events and related info. * @@ -537,6 +1443,77 @@ export class AddonCalendarProvider { }); } + /** + * Invalidates monthly events for all months. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllMonthlyEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getMonthlyEventsPrefixCacheKey()); + }); + } + + /** + * Invalidates monthly events for a certain months. + * + * @param {number} year Year. + * @param {number} month Month. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateMonthlyEvents(year: number, month: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getMonthlyEventsMonthPrefixCacheKey(year, month)); + }); + } + + /** + * Invalidates upcoming events for all courses and categories. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllUpcomingEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUpcomingEventsPrefixCacheKey()); + }); + } + + /** + * Invalidates upcoming events for a certain course or category. + * + * @param {number} [courseId] Course ID. + * @param {number} [categoryId] Category ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUpcomingEvents(courseId?: number, categoryId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUpcomingEventsCacheKey(courseId, categoryId)); + }); + } + + /** + * Invalidates look ahead setting. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateLookAhead(siteId?: string): Promise { + return this.userProvider.invalidateUserPreference('calendar_lookahead', siteId); + } + + /** + * Invalidates time format setting. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTimeFormat(siteId?: string): Promise { + return this.userProvider.invalidateUserPreference('calendar_timeformat', siteId); + } + /** * Check if Calendar is disabled in a certain site. * @@ -564,11 +1541,29 @@ export class AddonCalendarProvider { /** * Check if the get event by ID WS is available. * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if available. + * @since 3.4 + */ + isGetEventByIdAvailable(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isGetEventByIdAvailableInSite(site); + }).catch(() => { + return false; + }); + } + + /** + * Check if the get event by ID WS is available in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. * @return {boolean} Whether it's available. * @since 3.4 */ - isGetEventByIdAvailable(): boolean { - return this.sitesProvider.wsAvailableInCurrentSite('core_calendar_get_calendar_event_by_id'); + isGetEventByIdAvailableInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_calendar_get_calendar_event_by_id'); } /** @@ -591,7 +1586,7 @@ export class AddonCalendarProvider { return this.isDisabled(siteId).then((disabled) => { if (!disabled) { // Get first events. - return this.getEventsList(undefined, undefined, siteId).then((events) => { + return this.getEventsList(undefined, undefined, undefined, siteId).then((events) => { return this.scheduleEventsNotifications(events, siteId); }); } @@ -687,7 +1682,7 @@ export class AddonCalendarProvider { if (timeEnd <= new Date().getTime()) { // The event has finished already, don't schedule it. - return this.deleteEvent(event.id, siteId); + return this.deleteLocalEvent(event.id, siteId); } return this.getEventReminders(event.id, siteId).then((reminders) => { @@ -736,11 +1731,12 @@ export class AddonCalendarProvider { } }); }).then(() => { + // Don't store data that can be calculated like formattedtime, iscategoryevent, etc. const eventRecord = { id: event.id, name: event.name, description: event.description, - format: event.format, + format: event.descriptionformat || event.format, eventtype: event.eventtype, courseid: event.courseid, timestart: event.timestart, @@ -755,7 +1751,25 @@ export class AddonCalendarProvider { visible: event.visible, uuid: event.uuid, sequence: event.sequence, - subscriptionid: event.subscriptionid + subscriptionid: event.subscriptionid, + location: event.location, + eventcount: event.eventcount, + timesort: event.timesort, + category: event.category ? JSON.stringify(event.category) : undefined, + course: event.course ? JSON.stringify(event.course) : undefined, + subscription: event.subscription ? JSON.stringify(event.subscription) : undefined, + canedit: event.canedit ? 1 : 0, + candelete: event.candelete ? 1 : 0, + deleteurl: event.deleteurl, + editurl: event.editurl, + viewurl: event.viewurl, + isactionevent: event.isactionevent ? 1 : 0, + url: event.url, + islastday: event.islastday ? 1 : 0, + popupname: event.popupname, + mindaytimestamp: event.mindaytimestamp, + maxdaytimestamp: event.maxdaytimestamp, + draggable: event.draggable, }; return site.getDb().insertRecord(AddonCalendarProvider.EVENTS_TABLE, eventRecord); @@ -780,4 +1794,88 @@ export class AddonCalendarProvider { })); }); } + + /** + * Submit a calendar event. + * + * @param {number} eventId ID of the event. If undefined/null, create a new event. + * @param {any} formData Form data. + * @param {number} [timeCreated] The time the event was created. Only if modifying a new offline event. + * @param {boolean} [forceOffline] True to always save it in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{sent: boolean, event: any}>} Promise resolved with the event and a boolean indicating if data was + * sent to server or stored in offline. + */ + submitEvent(eventId: number, formData: any, timeCreated?: number, forceOffline?: boolean, siteId?: string): + Promise<{sent: boolean, event: any}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Function to store the event to be synchronized later. + const storeOffline = (): Promise<{sent: boolean, event: any}> => { + return this.calendarOffline.saveEvent(eventId, formData, timeCreated, siteId).then((event) => { + return {sent: false, event: event}; + }); + }; + + if (forceOffline || !this.appProvider.isOnline()) { + // App is offline, store the event. + return storeOffline(); + } + + // If the event is already stored, discard it first. + return this.calendarOffline.deleteEvent(eventId, siteId).then(() => { + return this.submitEventOnline(eventId, formData, siteId).then((event) => { + return {sent: true, event: event}; + }).catch((error) => { + if (error && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Submit an event, either to create it or to edit it. It will fail if offline or cannot connect. + * + * @param {number} eventId ID of the event. If undefined/null, create a new event. + * @param {any} formData Form data. + * @param {string} [siteId] Site ID. If not provided, current site. + * @return {Promise} Promise resolved when done. + */ + submitEventOnline(eventId: number, formData: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // Add data that is "hidden" in web. + formData.id = eventId || 0; + formData.userid = site.getUserId(); + formData.visible = 1; + formData.instance = 0; + + if (eventId > 0) { + formData['_qf__core_calendar_local_event_forms_update'] = 1; + } else { + formData['_qf__core_calendar_local_event_forms_create'] = 1; + } + + const params = { + formdata: this.utils.objectToGetParams(formData) + }; + + return site.write('core_calendar_submit_create_update_form', params).then((result) => { + if (result.validationerror) { + // Simulate a WS error. + return Promise.reject({ + message: this.translate.instant('core.invalidformdata'), + errorcode: 'validationerror' + }); + } + + return result.event; + }); + }); + } } diff --git a/src/addon/calendar/providers/helper.ts b/src/addon/calendar/providers/helper.ts index a1a85759dcd..7fd2226599b 100644 --- a/src/addon/calendar/providers/helper.ts +++ b/src/addon/calendar/providers/helper.ts @@ -14,7 +14,13 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonCalendarProvider } from './calendar'; +import { CoreConstants } from '@core/constants'; +import { CoreConfigProvider } from '@providers/config'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import * as moment from 'moment'; /** * Service that provides some features regarding lists of courses and categories. @@ -31,10 +37,94 @@ export class AddonCalendarHelperProvider { category: 'fa-cubes' }; - constructor(logger: CoreLoggerProvider, private courseProvider: CoreCourseProvider) { + constructor(logger: CoreLoggerProvider, + private courseProvider: CoreCourseProvider, + private sitesProvider: CoreSitesProvider, + private calendarProvider: AddonCalendarProvider, + private configProvider: CoreConfigProvider, + private utils: CoreUtilsProvider) { this.logger = logger.getInstance('AddonCalendarHelperProvider'); } + /** + * Calculate some day data based on a list of events for that day. + * + * @param {any} day Day. + * @param {any[]} events Events. + */ + calculateDayData(day: any, events: any[]): void { + day.hasevents = events.length > 0; + day.haslastdayofevent = false; + + const types = {}; + events.forEach((event) => { + types[event.formattedType || event.eventtype] = true; + + if (event.islastday) { + day.haslastdayofevent = true; + } + }); + + day.calendareventtypes = Object.keys(types); + } + + /** + * Check if current user can create/edit events. + * + * @param {number} [courseId] Course ID. If not defined, site calendar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether the user can create events. + */ + canEditEvents(courseId?: number, siteId?: string): Promise { + return this.calendarProvider.canEditEvents(siteId).then((canEdit) => { + if (!canEdit) { + return false; + } + + // Site allows creating events. Check if the user has permissions to do so. + return this.calendarProvider.getAllowedEventTypes(courseId, siteId).then((types) => { + return Object.keys(types).length > 0; + }); + }).catch(() => { + return false; + }); + } + + /** + * Classify events into their respective months and days. If an event duration covers more than one day, + * it will be included in all the days it lasts. + * + * @param {any[]} events Events to classify. + * @return {{[monthId: string]: {[day: number]: any[]}}} Object with the classified events. + */ + classifyIntoMonths(events: any[]): {[monthId: string]: {[day: number]: any[]}} { + + const result = {}; + + events.forEach((event) => { + const treatedDay = moment(new Date(event.timestart * 1000)), + endDay = moment(new Date((event.timestart + (event.timeduration || 0)) * 1000)); + + // Add the event to all the days it lasts. + while (!treatedDay.isAfter(endDay, 'day')) { + const monthId = this.getMonthId(treatedDay.year(), treatedDay.month() + 1), + day = treatedDay.date(); + + if (!result[monthId]) { + result[monthId] = {}; + } + if (!result[monthId][day]) { + result[monthId][day] = []; + } + result[monthId][day].push(event); + + treatedDay.add(1, 'day'); // Treat next day. + } + }); + + return result; + } + /** * Convenience function to format some event data to be rendered. * @@ -46,5 +136,334 @@ export class AddonCalendarHelperProvider { e.icon = this.courseProvider.getModuleIconSrc(e.modulename); e.moduleIcon = e.icon; } + + e.formattedType = this.calendarProvider.getEventType(e); + + if (typeof e.duration != 'undefined') { + // It's an offline event, add some calculated data. + e.format = 1; + e.visible = 1; + + if (e.duration == 1) { + e.timeduration = e.timedurationuntil - e.timestart; + } else if (e.duration == 2) { + e.timeduration = e.timedurationminutes * CoreConstants.SECONDS_MINUTE; + } else { + e.timeduration = 0; + } + } + } + + /** + * Get options (name & value) for each allowed event type. + * + * @param {any} eventTypes Result of getAllowedEventTypes. + * @return {{name: string, value: string}[]} Options. + */ + getEventTypeOptions(eventTypes: any): {name: string, value: string}[] { + const options = []; + + if (eventTypes.user) { + options.push({name: 'core.user', value: AddonCalendarProvider.TYPE_USER}); + } + if (eventTypes.group) { + options.push({name: 'core.group', value: AddonCalendarProvider.TYPE_GROUP}); + } + if (eventTypes.course) { + options.push({name: 'core.course', value: AddonCalendarProvider.TYPE_COURSE}); + } + if (eventTypes.category) { + options.push({name: 'core.category', value: AddonCalendarProvider.TYPE_CATEGORY}); + } + if (eventTypes.site) { + options.push({name: 'core.site', value: AddonCalendarProvider.TYPE_SITE}); + } + + return options; + } + + /** + * Get the month "id" (year + month). + * + * @param {number} year Year. + * @param {number} month Month. + * @return {string} The "id". + */ + getMonthId(year: number, month: number): string { + return year + '#' + month; + } + + /** + * Get weeks of a month in offline (with no events). + * + * The result has the same structure than getMonthlyEvents, but it only contains fields that are actually used by the app. + * + * @param {number} year Year to get. + * @param {number} month Month to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the response. + */ + getOfflineMonthWeeks(year: number, month: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // Get starting week day user preference, fallback to site configuration. + const startWeekDay = site.getStoredConfig('calendar_startwday'); + + return this.configProvider.get(AddonCalendarProvider.STARTING_WEEK_DAY, startWeekDay); + }).then((startWeekDay) => { + const today = moment(); + const isCurrentMonth = today.year() == year && today.month() == month - 1; + const weeks = []; + + let date = moment({year, month: month - 1, date: 1}); + for (let mday = 1; mday <= date.daysInMonth(); mday++) { + date = moment({year, month: month - 1, date: mday}); + + // Add new week and calculate prepadding. + if (!weeks.length || date.day() == startWeekDay) { + const prepaddingLength = (date.day() - startWeekDay + 7) % 7; + const prepadding = []; + for (let i = 0; i < prepaddingLength; i++) { + prepadding.push(i); + } + weeks.push({ prepadding, postpadding: [], days: []}); + } + + // Calculate postpadding of last week. + if (mday == date.daysInMonth()) { + const postpaddingLength = (startWeekDay - date.day() + 6) % 7; + const postpadding = []; + for (let i = 0; i < postpaddingLength; i++) { + postpadding.push(i); + } + weeks[weeks.length - 1].postpadding = postpadding; + } + + // Add day to current week. + weeks[weeks.length - 1].days.push({ + events: [], + hasevents: false, + mday: date.date(), + isweekend: date.day() == 0 || date.day() == 6, + istoday: isCurrentMonth && today.date() == date.date(), + calendareventtypes: [], + }); + } + + return {weeks, daynames: [{dayno: startWeekDay}]}; + }); + } + + /** + * Check if the data of an event has changed. + * + * @param {any} data Current data. + * @param {any} [original] Original data. + * @return {boolean} True if data has changed, false otherwise. + */ + hasEventDataChanged(data: any, original?: any): boolean { + if (!original) { + // There is no original data, assume it hasn't changed. + return false; + } + + // Check the fields that don't depend on any other. + if (data.name != original.name || data.timestart != original.timestart || data.eventtype != original.eventtype || + data.description != original.description || data.location != original.location || + data.duration != original.duration || data.repeat != original.repeat) { + return true; + } + + // Check data that depends on eventtype. + if ((data.eventtype == AddonCalendarProvider.TYPE_CATEGORY && data.categoryid != original.categoryid) || + (data.eventtype == AddonCalendarProvider.TYPE_COURSE && data.courseid != original.courseid) || + (data.eventtype == AddonCalendarProvider.TYPE_GROUP && data.groupcourseid != original.groupcourseid && + data.groupid != original.groupid)) { + return true; + } + + // Check data that depends on duration. + if ((data.duration == 1 && data.timedurationuntil != original.timedurationuntil) || + (data.duration == 2 && data.timedurationminutes != original.timedurationminutes)) { + return true; + } + + if (data.repeat && data.repeats != original.repeats) { + return true; + } + + return false; + } + + /** + * Check if an event should be displayed based on the filter. + * + * @param {any} event Event object. + * @param {number} courseId Course ID to filter. + * @param {number} categoryId Category ID the course belongs to. + * @param {any} categories Categories indexed by ID. + * @return {boolean} Whether it should be displayed. + */ + shouldDisplayEvent(event: any, courseId: number, categoryId: number, categories: any): boolean { + if (event.eventtype == 'user' || event.eventtype == 'site') { + // User or site event, display it. + return true; + } + + if (event.eventtype == 'category') { + if (!event.categoryid || !Object.keys(categories).length) { + // We can't tell if the course belongs to the category, display them all. + return true; + } + + if (event.categoryid == categoryId) { + // The event is in the same category as the course, display it. + return true; + } + + // Check parent categories. + let category = categories[categoryId]; + while (category) { + if (!category.parent) { + // Category doesn't have parent, stop. + break; + } + + if (event.categoryid == category.parent) { + return true; + } + category = categories[category.parent]; + } + + return false; + } + + // Show the event if it is from site home or if it matches the selected course. + return event.course && (event.course.id == this.sitesProvider.getCurrentSiteHomeId() || event.course.id == courseId); + } + + /** + * Refresh the month & day for several created/edited/deleted events, and invalidate the months & days + * for their repeated events if needed. + * + * @param {{event: any, repeated: number}[]} events Events that have been touched and number of times each event is repeated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when done. + */ + refreshAfterChangeEvents(events: {event: any, repeated: number}[], siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const fetchTimestarts = [], + invalidateTimestarts = []; + + // Always fetch upcoming events. + const upcomingPromise = this.calendarProvider.getUpcomingEvents(undefined, undefined, true, site.id).catch(() => { + // Ignore errors. + }); + + // Invalidate the events and get the timestarts so we can invalidate months & days. + return this.utils.allPromises([upcomingPromise].concat(events.map((eventData) => { + + if (eventData.repeated > 1) { + if (eventData.event.repeatid) { + // Being edited or deleted. + // We need to calculate the days to invalidate because the event date could have changed. + // We don't know if the repeated events are before or after this one, invalidate them all. + fetchTimestarts.push(eventData.event.timestart); + + for (let i = 1; i < eventData.repeated; i++) { + invalidateTimestarts.push(eventData.event.timestart + CoreConstants.SECONDS_DAY * 7 * i); + invalidateTimestarts.push(eventData.event.timestart - CoreConstants.SECONDS_DAY * 7 * i); + } + + // Get the repeated events to invalidate them. + return this.calendarProvider.getLocalEventsByRepeatIdFromLocalDb(eventData.event.repeatid, site.id) + .then((events) => { + + return this.utils.allPromises(events.map((event) => { + return this.calendarProvider.invalidateEvent(event.id); + })); + }); + } else { + // Being added. + let time = eventData.event.timestart; + fetchTimestarts.push(time); + + while (eventData.repeated > 1) { + time += CoreConstants.SECONDS_DAY * 7; + eventData.repeated--; + invalidateTimestarts.push(time); + } + + return Promise.resolve(); + } + } else { + // Not repeated. + fetchTimestarts.push(eventData.event.timestart); + + return this.calendarProvider.invalidateEvent(eventData.event.id); + } + + }))).finally(() => { + const treatedMonths = {}, + treatedDays = {}; + + return this.utils.allPromises([ + this.calendarProvider.invalidateAllUpcomingEvents(), + + // Fetch or invalidate months and days. + this.utils.allPromises(fetchTimestarts.concat(invalidateTimestarts).map((time, index) => { + const promises = [], + day = moment(new Date(time * 1000)), + monthId = this.getMonthId(day.year(), day.month() + 1), + dayId = monthId + '#' + day.date(); + + if (!treatedMonths[monthId]) { + // Month not treated already, do it now. + treatedMonths[monthId] = monthId; + + if (index < fetchTimestarts.length) { + promises.push(this.calendarProvider.getMonthlyEvents(day.year(), day.month() + 1, undefined, + undefined, true, site.id).catch(() => { + + // Ignore errors. + })); + } else { + promises.push(this.calendarProvider.invalidateMonthlyEvents(day.year(), day.month() + 1, site.id)); + } + } + + if (!treatedDays[dayId]) { + // Day not invalidated already, do it now. + treatedDays[dayId] = dayId; + + if (index < fetchTimestarts.length) { + promises.push(this.calendarProvider.getDayEvents(day.year(), day.month() + 1, day.date(), + undefined, undefined, true, site.id).catch(() => { + + // Ignore errors. + })); + } else { + promises.push(this.calendarProvider.invalidateDayEvents(day.year(), day.month() + 1, day.date(), + site.id)); + } + } + + return this.utils.allPromises(promises); + })) + ]); + }); + }); + } + + /** + * Refresh the month & day for a created/edited/deleted event, and invalidate the months & days + * for their repeated events if needed. + * + * @param {any} event Event that has been touched. + * @param {number} repeated Number of times the event is repeated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when done. + */ + refreshAfterChangeEvent(event: any, repeated: number, siteId?: string): Promise { + return this.refreshAfterChangeEvents([{event: event, repeated: repeated}], siteId); } } diff --git a/src/addon/calendar/providers/mainmenu-handler.ts b/src/addon/calendar/providers/mainmenu-handler.ts index 2769f0e0bfb..0568eeb01ea 100644 --- a/src/addon/calendar/providers/mainmenu-handler.ts +++ b/src/addon/calendar/providers/mainmenu-handler.ts @@ -44,7 +44,7 @@ export class AddonCalendarMainMenuHandler implements CoreMainMenuHandler { return { icon: 'calendar', title: 'addon.calendar.calendar', - page: 'AddonCalendarListPage', + page: this.calendarProvider.canViewMonthInSite() ? 'AddonCalendarIndexPage' : 'AddonCalendarListPage', class: 'addon-calendar-handler' }; } diff --git a/src/addon/calendar/providers/sync-cron-handler.ts b/src/addon/calendar/providers/sync-cron-handler.ts new file mode 100644 index 00000000000..6aabcca742f --- /dev/null +++ b/src/addon/calendar/providers/sync-cron-handler.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { AddonCalendarSyncProvider } from './calendar-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonCalendarSyncCronHandler implements CoreCronHandler { + name = 'AddonCalendarSyncCronHandler'; + + constructor(private calendarSync: AddonCalendarSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return this.calendarSync.syncAllEvents(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return this.calendarSync.syncInterval; + } +} diff --git a/src/addon/calendar/providers/view-link-handler.ts b/src/addon/calendar/providers/view-link-handler.ts new file mode 100644 index 00000000000..e0ba641c072 --- /dev/null +++ b/src/addon/calendar/providers/view-link-handler.ts @@ -0,0 +1,113 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonCalendarProvider } from './calendar'; + +/** + * Content links handler for calendar view page. + */ +@Injectable() +export class AddonCalendarViewLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonCalendarViewLinkHandler'; + pattern = /\/calendar\/view\.php/; + + protected SUPPORTED_VIEWS = ['month', 'mini', 'minithree', 'day', 'upcoming', 'upcoming_mini']; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private calendarProvider: AddonCalendarProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + if (!params.view || params.view == 'month' || params.view == 'mini' || params.view == 'minithree') { + // Monthly view, open the calendar tab. + const stateParams: any = { + courseId: params.course + }, + timestamp = params.time ? params.time * 1000 : Date.now(); + + const date = new Date(timestamp); + stateParams.year = date.getFullYear(); + stateParams.month = date.getMonth() + 1; + + this.linkHelper.goInSite(navCtrl, 'AddonCalendarIndexPage', stateParams, siteId); // @todo: Add checkMenu param. + + } else if (params.view == 'day') { + // Daily view, open the page. + const stateParams: any = { + courseId: params.course + }, + timestamp = params.time ? params.time * 1000 : Date.now(); + + const date = new Date(timestamp); + stateParams.year = date.getFullYear(); + stateParams.month = date.getMonth() + 1; + stateParams.day = date.getDate(); + + this.linkHelper.goInSite(navCtrl, 'AddonCalendarDayPage', stateParams, siteId); + + } else if (params.view == 'upcoming' || params.view == 'upcoming_mini') { + // Upcoming view, open the calendar tab. + const stateParams: any = { + courseId: params.course, + upcoming: true, + }; + + this.linkHelper.goInSite(navCtrl, 'AddonCalendarIndexPage', stateParams, siteId); // @todo: Add checkMenu param. + + } + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + if (params.view && this.SUPPORTED_VIEWS.indexOf(params.view) == -1) { + // This type of view isn't supported in the app. + return false; + } + + return this.calendarProvider.isDisabled(siteId).then((disabled) => { + if (disabled) { + return false; + } + + return this.calendarProvider.canViewMonth(siteId); + }); + } +} diff --git a/src/addon/competency/pages/competency/competency.html b/src/addon/competency/pages/competency/competency.html index dc048e44659..4f66d9dc1c4 100644 --- a/src/addon/competency/pages/competency/competency.html +++ b/src/addon/competency/pages/competency/competency.html @@ -76,7 +76,7 @@

{{ 'addon.competency.evidence' | translate }}

{{ 'addon.competency.noevidence' | translate }}

- +

{{ evidence.actionuser.fullname }}

{{ evidence.timemodified * 1000 | coreFormatDate }}

diff --git a/src/addon/competency/pages/competency/competency.ts b/src/addon/competency/pages/competency/competency.ts index dc7e76c9d16..c3930b9452b 100644 --- a/src/addon/competency/pages/competency/competency.ts +++ b/src/addon/competency/pages/competency/competency.ts @@ -151,15 +151,4 @@ export class AddonCompetencyCompetencyPage { const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; navCtrl.push('AddonCompetencyCompetencySummaryPage', {competencyId}); } - - /** - * Opens the profile of a user. - * - * @param {number} userId - */ - openUserProfile(userId: number): void { - // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav. - const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; - navCtrl.push('CoreUserProfilePage', {userId, courseId: this.courseId}); - } } diff --git a/src/addon/competency/providers/course-option-handler.ts b/src/addon/competency/providers/course-option-handler.ts index f05219300ee..2f817699504 100644 --- a/src/addon/competency/providers/course-option-handler.ts +++ b/src/addon/competency/providers/course-option-handler.ts @@ -63,10 +63,10 @@ export class AddonCompetencyCourseOptionHandler implements CoreCourseOptionsHand * Returns the data needed to render the handler. * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, courseId: number): CoreCourseOptionsHandlerData | Promise { + getDisplayData?(injector: Injector, course: any): CoreCourseOptionsHandlerData | Promise { return { title: 'addon.competency.competencies', class: 'addon-competency-course-handler', diff --git a/src/addon/competency/providers/plans-link-handler.ts b/src/addon/competency/providers/plans-link-handler.ts index a99280f3adc..da08f33dacd 100644 --- a/src/addon/competency/providers/plans-link-handler.ts +++ b/src/addon/competency/providers/plans-link-handler.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { AddonCompetencyProvider } from './competency'; /** @@ -26,7 +26,7 @@ export class AddonCompetencyPlansLinkHandler extends CoreContentLinksHandlerBase name = 'AddonCompetencyPlansLinkHandler'; pattern = /\/admin\/tool\/lp\/plans\.php/; - constructor(private loginHelper: CoreLoginHelperProvider, private competencyProvider: AddonCompetencyProvider) { + constructor(private linkHelper: CoreContentLinksHelperProvider, private competencyProvider: AddonCompetencyProvider) { super(); } @@ -44,8 +44,8 @@ export class AddonCompetencyPlansLinkHandler extends CoreContentLinksHandlerBase return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect('AddonCompetencyPlanListPage', { userId: params.userid }, siteId); + this.linkHelper.goInSite(navCtrl, 'AddonCompetencyPlanListPage', { userId: params.userid }, siteId, + typeof params.userid == 'undefined'); } }]; } diff --git a/src/addon/competency/providers/user-handler.ts b/src/addon/competency/providers/user-handler.ts index 3b04349f6cb..7cdbaadac11 100644 --- a/src/addon/competency/providers/user-handler.ts +++ b/src/addon/competency/providers/user-handler.ts @@ -111,7 +111,7 @@ export class AddonCompetencyUserHandler implements CoreUserProfileHandler { action: (event, navCtrl, user, courseId): void => { event.preventDefault(); event.stopPropagation(); - // Always use redirect to make it the new history root (to avoid "loops" in history). + this.linkHelper.goInSite(navCtrl, 'AddonCompetencyCourseCompetenciesPage', {courseId, userId: user.id}); } }; @@ -123,7 +123,7 @@ export class AddonCompetencyUserHandler implements CoreUserProfileHandler { action: (event, navCtrl, user, courseId): void => { event.preventDefault(); event.stopPropagation(); - // Always use redirect to make it the new history root (to avoid "loops" in history). + this.linkHelper.goInSite(navCtrl, 'AddonCompetencyPlanListPage', {userId: user.id}); } }; diff --git a/src/addon/coursecompletion/providers/course-option-handler.ts b/src/addon/coursecompletion/providers/course-option-handler.ts index d6a08f6e98e..d0483b68a78 100644 --- a/src/addon/coursecompletion/providers/course-option-handler.ts +++ b/src/addon/coursecompletion/providers/course-option-handler.ts @@ -97,6 +97,12 @@ export class AddonCourseCompletionCourseOptionHandler implements CoreCourseOptio return this.courseCompletionProvider.getCompletion(course.id, undefined, { getFromCache: false, emergencyCache: false + }).catch((error) => { + if (error && error.errorcode == 'notenroled') { + // Not enrolled error, probably a teacher. Ignore error. + } else { + return Promise.reject(error); + } }); } } diff --git a/src/addon/coursecompletion/providers/coursecompletion.ts b/src/addon/coursecompletion/providers/coursecompletion.ts index 262bb8e3b9a..0a79a47669f 100644 --- a/src/addon/coursecompletion/providers/coursecompletion.ts +++ b/src/addon/coursecompletion/providers/coursecompletion.ts @@ -110,6 +110,7 @@ export class AddonCourseCompletionProvider { preSets.cacheKey = this.getCompletionCacheKey(courseId, userId); preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_SOMETIMES; + preSets.cacheErrors = ['notenroled']; return site.read('core_completion_get_course_completion_status', data, preSets).then((data) => { if (data.completionstatus) { diff --git a/src/addon/messages/pages/discussion/discussion.scss b/src/addon/messages/pages/discussion/discussion.scss index ddffe09009d..f17702e2435 100644 --- a/src/addon/messages/pages/discussion/discussion.scss +++ b/src/addon/messages/pages/discussion/discussion.scss @@ -5,9 +5,6 @@ $item-message-note-font-size: 75% !default; $item-message-mine-bg: $gray-light !default; ion-app.app-root page-addon-messages-discussion { - .toolbar-title { - padding: 0; - } ion-content { background-color: $gray-lighter !important; @@ -49,7 +46,6 @@ ion-app.app-root page-addon-messages-discussion { @include core-transition(width); // This is needed to display bubble tails. overflow: visible; - contain: none; core-format-text > p:only-child { display: inline; @@ -193,6 +189,8 @@ ion-app.app-root page-addon-messages-discussion { } .toolbar-title { + padding: 0; + img { @include margin-horizontal(null, 6px); } @@ -209,6 +207,10 @@ ion-app.app-root page-addon-messages-discussion { ion-icon { @include margin-horizontal(6px, null); } + + &.toolbar-title-ios { + justify-content: center; + } } } diff --git a/src/addon/messages/pages/group-conversations/group-conversations.scss b/src/addon/messages/pages/group-conversations/group-conversations.scss index 80246e1a20c..d4dd9a82162 100644 --- a/src/addon/messages/pages/group-conversations/group-conversations.scss +++ b/src/addon/messages/pages/group-conversations/group-conversations.scss @@ -40,6 +40,6 @@ ion-app.app-root .addon-message-discussion { ion-app.app-root .addon-message-discussion { h2 { - margin-top: 6px; + margin-top: 10px; } } \ No newline at end of file diff --git a/src/addon/messages/providers/contact-request-link-handler.ts b/src/addon/messages/providers/contact-request-link-handler.ts index 1c262f601e4..aebf2e8cd32 100644 --- a/src/addon/messages/providers/contact-request-link-handler.ts +++ b/src/addon/messages/providers/contact-request-link-handler.ts @@ -43,7 +43,6 @@ export class AddonMessagesContactRequestLinkHandler extends CoreContentLinksHand CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'AddonMessagesContactsPage', {}, siteId); } }]; diff --git a/src/addon/messages/providers/discussion-link-handler.ts b/src/addon/messages/providers/discussion-link-handler.ts index 869e9461b2c..b916798c621 100644 --- a/src/addon/messages/providers/discussion-link-handler.ts +++ b/src/addon/messages/providers/discussion-link-handler.ts @@ -49,7 +49,6 @@ export class AddonMessagesDiscussionLinkHandler extends CoreContentLinksHandlerB const stateParams = { userId: parseInt(params.id || params.user2, 10) }; - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'AddonMessagesDiscussionPage', stateParams, siteId); } }]; diff --git a/src/addon/messages/providers/index-link-handler.ts b/src/addon/messages/providers/index-link-handler.ts index 974c6ddeff2..400bd4eb1af 100644 --- a/src/addon/messages/providers/index-link-handler.ts +++ b/src/addon/messages/providers/index-link-handler.ts @@ -49,7 +49,6 @@ export class AddonMessagesIndexLinkHandler extends CoreContentLinksHandlerBase { pageName = 'AddonMessagesGroupConversationsPage'; } - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, pageName, undefined, siteId); } }]; diff --git a/src/addon/messages/providers/user-send-message-handler.ts b/src/addon/messages/providers/user-send-message-handler.ts index a80b90adeef..8fd8982d9d1 100644 --- a/src/addon/messages/providers/user-send-message-handler.ts +++ b/src/addon/messages/providers/user-send-message-handler.ts @@ -76,7 +76,6 @@ export class AddonMessagesSendMessageUserHandler implements CoreUserProfileHandl showKeyboard: true, userId: user.id }; - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'AddonMessagesDiscussionPage', pageParams); } }; diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index 013c6b3783c..26d57888e58 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -204,8 +204,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo this.showNumbers = groupInfo.groups.length == 0 || this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.5'); - return this.setGroup(this.group || (groupInfo.groups && groupInfo.groups[0] && groupInfo.groups[0].id) || - 0); + return this.setGroup(this.groupsProvider.validateGroupId(this.group, groupInfo)); }); } diff --git a/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html index 0ff33520222..06bebe6cb40 100644 --- a/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html @@ -1,7 +1,7 @@ - + -
+

{{ user.fullname }}

@@ -110,7 +110,7 @@

{{ 'addon.mod_assign.attemptnumber' | translate }}

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate: {$a: ''} }}

- +

{{ user.fullname }}

@@ -208,7 +208,7 @@

{{ 'addon.mod_assign.attemptsettings' | translate }}

- +

{{ 'addon.mod_assign.gradedby' | translate }}

{{ grader.fullname }}

diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts index c3f655855e4..49489a9a547 100644 --- a/src/addon/mod/assign/components/submission/submission.ts +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -620,17 +620,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { }); } - /** - * Open a user profile. - * - * @param {number} userId User to open. - */ - openUserProfile(userId: number): void { - // Open a user profile. If this component is inside a split view, use the master nav to open it. - const navCtrl = this.splitviewCtrl ? this.splitviewCtrl.getMasterNav() : this.navCtrl; - navCtrl.push('CoreUserProfilePage', { userId: userId, courseId: this.courseId }); - } - /** * Set the submission status name and class. * @@ -909,8 +898,8 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { if (this.assign.teamsubmission) { if (response.lastattempt.submissiongroup) { // Get the name of the group. - promises.push(this.groupsProvider.getActivityAllowedGroups(this.assign.cmid).then((groups) => { - groups.forEach((group) => { + promises.push(this.groupsProvider.getActivityAllowedGroups(this.assign.cmid).then((result) => { + result.groups.forEach((group) => { if (group.id == response.lastattempt.submissiongroup) { this.lastAttempt.submissiongroupname = group.name; } diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.ts b/src/addon/mod/assign/pages/submission-list/submission-list.ts index abf2cd77834..a8bcf8c6dec 100644 --- a/src/addon/mod/assign/pages/submission-list/submission-list.ts +++ b/src/addon/mod/assign/pages/submission-list/submission-list.ts @@ -128,7 +128,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { return this.groupsProvider.getActivityGroupInfo(this.assign.cmid, false).then((groupInfo) => { this.groupInfo = groupInfo; - return this.setGroup(this.groupId || (groupInfo.groups && groupInfo.groups[0] && groupInfo.groups[0].id) || 0); + return this.setGroup(this.groupsProvider.validateGroupId(this.groupId, groupInfo)); }); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); diff --git a/src/addon/mod/assign/providers/helper.ts b/src/addon/mod/assign/providers/helper.ts index f0c93976667..5f8f5df2195 100644 --- a/src/addon/mod/assign/providers/helper.ts +++ b/src/addon/mod/assign/providers/helper.ts @@ -165,11 +165,11 @@ export class AddonModAssignHelperProvider { } // If no participants returned and all groups specified, get participants by groups. - return this.groupsProvider.getActivityAllowedGroupsIfEnabled(assign.cmid, undefined, siteId).then((userGroups) => { + return this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, siteId).then((info) => { const promises = [], participants = {}; - userGroups.forEach((userGroup) => { + info.groups.forEach((userGroup) => { promises.push(this.assignProvider.listParticipants(assign.id, userGroup.id, ignoreCache, siteId) .then((parts) => { // Do not get repeated users. diff --git a/src/addon/mod/assign/providers/prefetch-handler.ts b/src/addon/mod/assign/providers/prefetch-handler.ts index a5f1e6fc913..9c76f2ec8ea 100644 --- a/src/addon/mod/assign/providers/prefetch-handler.ts +++ b/src/addon/mod/assign/providers/prefetch-handler.ts @@ -293,9 +293,9 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan return this.assignProvider.getSubmissions(assign.id, true, siteId).then((data) => { const promises = []; - if (data.canviewsubmissions) { - // Teacher, prefetch all submissions. - promises.push(this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, siteId).then((groupInfo) => { + promises.push(this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, siteId).then((groupInfo) => { + if (data.canviewsubmissions) { + // Teacher, prefetch all submissions. const groupProms = []; if (!groupInfo.groups || groupInfo.groups.length == 0) { groupInfo.groups = [{id: 0}]; @@ -354,8 +354,8 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan }); return Promise.all(groupProms); - })); - } + } + })); // Prefetch own submission, we need to do this for teachers too so the response with error is cached. promises.push( @@ -370,9 +370,6 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan }) ); - promises.push(this.groupsProvider.activityHasGroups(assign.cmid, siteId, true)); - promises.push(this.groupsProvider.getActivityAllowedGroups(assign.cmid, undefined, siteId, true)); - return Promise.all(promises); }); } diff --git a/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html b/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html index be2ba466f32..8040cdfeb15 100644 --- a/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html +++ b/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html @@ -1,4 +1,4 @@ -
+

{{plugin.name}}

diff --git a/src/addon/mod/assign/submission/comments/component/comments.ts b/src/addon/mod/assign/submission/comments/component/comments.ts index 98c8be9e0fd..bd6bb57c2b6 100644 --- a/src/addon/mod/assign/submission/comments/component/comments.ts +++ b/src/addon/mod/assign/submission/comments/component/comments.ts @@ -48,7 +48,7 @@ export class AddonModAssignSubmissionCommentsComponent extends AddonModAssignSub /** * Show the comments. */ - showComments(): void { - this.commentsComponent && this.commentsComponent.openComments(); + showComments(e?: Event): void { + this.commentsComponent && this.commentsComponent.openComments(e); } } diff --git a/src/addon/mod/book/book.module.ts b/src/addon/mod/book/book.module.ts index cc06e008df6..ea425d1c8c3 100644 --- a/src/addon/mod/book/book.module.ts +++ b/src/addon/mod/book/book.module.ts @@ -22,6 +22,8 @@ import { AddonModBookPrefetchHandler } from './providers/prefetch-handler'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; +import { AddonModBookTagAreaHandler } from './providers/tag-area-handler'; // List of providers (without handlers). export const ADDON_MOD_BOOK_PROVIDERS: any[] = [ @@ -39,18 +41,21 @@ export const ADDON_MOD_BOOK_PROVIDERS: any[] = [ AddonModBookModuleHandler, AddonModBookLinkHandler, AddonModBookListLinkHandler, - AddonModBookPrefetchHandler + AddonModBookPrefetchHandler, + AddonModBookTagAreaHandler ] }) export class AddonModBookModule { constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModBookModuleHandler, contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModBookLinkHandler, prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModBookPrefetchHandler, - listLinkHandler: AddonModBookListLinkHandler) { + listLinkHandler: AddonModBookListLinkHandler, tagAreaDelegate: CoreTagAreaDelegate, + tagAreaHandler: AddonModBookTagAreaHandler) { moduleDelegate.registerHandler(moduleHandler); contentLinksDelegate.registerHandler(linkHandler); contentLinksDelegate.registerHandler(listLinkHandler); prefetchDelegate.registerHandler(prefetchHandler); + tagAreaDelegate.registerHandler(tagAreaHandler); } } diff --git a/src/addon/mod/book/components/components.module.ts b/src/addon/mod/book/components/components.module.ts index 54e83ef50dd..4cc338b6eeb 100644 --- a/src/addon/mod/book/components/components.module.ts +++ b/src/addon/mod/book/components/components.module.ts @@ -20,6 +20,7 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModBookIndexComponent } from './index/index'; +import { CoreTagComponentsModule } from '@core/tag/components/components.module'; @NgModule({ declarations: [ @@ -31,7 +32,8 @@ import { AddonModBookIndexComponent } from './index/index'; TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, - CoreCourseComponentsModule + CoreCourseComponentsModule, + CoreTagComponentsModule ], providers: [ ], diff --git a/src/addon/mod/book/components/index/addon-mod-book-index.html b/src/addon/mod/book/components/index/addon-mod-book-index.html index 6f4e0be3ab0..13cc19b7e3e 100644 --- a/src/addon/mod/book/components/index/addon-mod-book-index.html +++ b/src/addon/mod/book/components/index/addon-mod-book-index.html @@ -21,6 +21,10 @@
+
+ {{ 'core.tag.tags' | translate }}: + +
diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts index 1b154d37392..569060aa335 100644 --- a/src/addon/mod/book/components/index/index.ts +++ b/src/addon/mod/book/components/index/index.ts @@ -19,6 +19,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseModuleMainResourceComponent } from '@core/course/classes/main-resource-component'; import { AddonModBookProvider, AddonModBookContentsMap, AddonModBookTocChapter } from '../../providers/book'; import { AddonModBookPrefetchHandler } from '../../providers/prefetch-handler'; +import { CoreTagProvider } from '@core/tag/providers/tag'; /** * Component that displays a book. @@ -34,6 +35,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp chapterContent: string; previousChapter: string; nextChapter: string; + tagsEnabled: boolean; protected chapters: AddonModBookTocChapter[]; protected currentChapter: string; @@ -41,7 +43,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp constructor(injector: Injector, private bookProvider: AddonModBookProvider, private courseProvider: CoreCourseProvider, private appProvider: CoreAppProvider, private prefetchDelegate: AddonModBookPrefetchHandler, - private modalCtrl: ModalController, @Optional() private content: Content) { + private modalCtrl: ModalController, private tagProvider: CoreTagProvider, @Optional() private content: Content) { super(injector); } @@ -51,6 +53,8 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp ngOnInit(): void { super.ngOnInit(); + this.tagsEnabled = this.tagProvider.areTagsAvailableInSite(); + this.loadContent(); } diff --git a/src/addon/mod/book/lang/en.json b/src/addon/mod/book/lang/en.json index 7d1140fe4dd..4f8f32f54ac 100644 --- a/src/addon/mod/book/lang/en.json +++ b/src/addon/mod/book/lang/en.json @@ -1,5 +1,6 @@ { "errorchapter": "Error reading chapter of book.", "modulenameplural": "Books", + "tagarea_book_chapters": "Book chapters", "toc": "Table of contents" } \ No newline at end of file diff --git a/src/addon/mod/book/providers/book.ts b/src/addon/mod/book/providers/book.ts index 1655ed164a1..74fd32706ba 100644 --- a/src/addon/mod/book/providers/book.ts +++ b/src/addon/mod/book/providers/book.ts @@ -24,6 +24,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreSite } from '@classes/site'; +import { CoreTagItem } from '@core/tag/providers/tag'; /** * A book chapter inside the toc list. @@ -52,7 +53,13 @@ export interface AddonModBookTocChapter { * Map of book contents. For each chapter it has its index URL and the list of paths of the files the chapter has. Each path * is identified by the relative path in the book, and the value is the URL of the file. */ -export type AddonModBookContentsMap = {[chapter: string]: {indexUrl?: string, paths: {[path: string]: string}}}; +export type AddonModBookContentsMap = { + [chapter: string]: { + indexUrl?: string, + paths: {[path: string]: string}, + tags?: CoreTagItem[] + } +}; /** * Service that provides some features for books. @@ -203,8 +210,9 @@ export class AddonModBookProvider { map[chapter] = map[chapter] || { paths: {} }; if (content.filename == 'index.html' && filepathIsChapter) { - // Index of the chapter, set indexUrl of the chapter. + // Index of the chapter, set indexUrl and tags of the chapter. map[chapter].indexUrl = content.fileurl; + map[chapter].tags = content.tags; } else { if (filepathIsChapter) { // It's a file in the root folder OR the WS isn't returning the filepath as it should (MDL-53671). diff --git a/src/addon/mod/book/providers/link-handler.ts b/src/addon/mod/book/providers/link-handler.ts index 899978d4b90..631885cbeca 100644 --- a/src/addon/mod/book/providers/link-handler.ts +++ b/src/addon/mod/book/providers/link-handler.ts @@ -46,7 +46,7 @@ export class AddonModBookLinkHandler extends CoreContentLinksModuleIndexHandler return [{ action: (siteId, navCtrl?): void => { this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, - this.useModNameToGetModule ? this.modName : undefined, modParams); + this.useModNameToGetModule ? this.modName : undefined, modParams, navCtrl); } }]; } diff --git a/src/addon/mod/book/providers/tag-area-handler.ts b/src/addon/mod/book/providers/tag-area-handler.ts new file mode 100644 index 00000000000..47c456cb655 --- /dev/null +++ b/src/addon/mod/book/providers/tag-area-handler.ts @@ -0,0 +1,75 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreTagHelperProvider } from '@core/tag/providers/helper'; +import { CoreTagFeedComponent } from '@core/tag/components/feed/feed'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { AddonModBookProvider } from './book'; + +/** + * Handler to support tags. + */ +@Injectable() +export class AddonModBookTagAreaHandler implements CoreTagAreaHandler { + name = 'AddonModBookTagAreaHandler'; + type = 'mod_book/book_chapters'; + + constructor(private tagHelper: CoreTagHelperProvider, private bookProvider: AddonModBookProvider, + private courseProvider: CoreCourseProvider, private urlUtils: CoreUrlUtilsProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.bookProvider.isPluginEnabled(); + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + const items = this.tagHelper.parseFeedContent(content); + + // Find module ids of the returned books, they are needed by the link delegate. + return Promise.all(items.map((item) => { + const params = this.urlUtils.extractUrlParams(item.url); + if (params.b && !params.id) { + const bookId = parseInt(params.b, 10); + + return this.courseProvider.getModuleBasicInfoByInstance(bookId, 'book').then((module) => { + item.url += '&id=' + module.id; + }); + } + })).then(() => { + return items; + }); + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreTagFeedComponent; + } +} diff --git a/src/addon/mod/chat/pages/chat/chat.ts b/src/addon/mod/chat/pages/chat/chat.ts index 99bcbec9416..41bd0e1cafc 100644 --- a/src/addon/mod/chat/pages/chat/chat.ts +++ b/src/addon/mod/chat/pages/chat/chat.ts @@ -65,7 +65,7 @@ export class AddonModChatChatPage { this.logger = logger.getInstance('AddonModChoiceChoicePage'); this.currentUserBeep = 'beep ' + sitesProvider.getCurrentSiteUserId(); this.isOnline = this.appProvider.isOnline(); - this.onlineObserver = network.onchange().subscribe((online) => { + this.onlineObserver = network.onchange().subscribe(() => { // Execute the callback in the Angular zone, so change detection doesn't stop working. zone.run(() => { this.isOnline = this.appProvider.isOnline(); diff --git a/src/addon/mod/chat/pages/sessions/sessions.ts b/src/addon/mod/chat/pages/sessions/sessions.ts index 35f26cb3364..373a38dce4c 100644 --- a/src/addon/mod/chat/pages/sessions/sessions.ts +++ b/src/addon/mod/chat/pages/sessions/sessions.ts @@ -70,14 +70,7 @@ export class AddonModChatSessionsPage { return this.groupsProvider.getActivityGroupInfo(this.cmId, false).then((groupInfo) => { this.groupInfo = groupInfo; - - if (groupInfo.groups && groupInfo.groups.length > 0) { - if (!groupInfo.groups.find((group) => group.id === this.groupId)) { - this.groupId = groupInfo.groups[0].id; - } - } else { - this.groupId = 0; - } + this.groupId = this.groupsProvider.validateGroupId(this.groupId, groupInfo); return this.chatProvider.getSessions(this.chatId, this.groupId, this.showAll); }).then((sessions) => { diff --git a/src/addon/mod/chat/pages/users/users.ts b/src/addon/mod/chat/pages/users/users.ts index a9f4f175a05..90e59df6d94 100644 --- a/src/addon/mod/chat/pages/users/users.ts +++ b/src/addon/mod/chat/pages/users/users.ts @@ -44,7 +44,7 @@ export class AddonModChatUsersPage { this.sessionId = navParams.get('sessionId'); this.isOnline = this.appProvider.isOnline(); this.currentUserId = this.sitesProvider.getCurrentSiteUserId(); - this.onlineObserver = network.onchange().subscribe((online) => { + this.onlineObserver = network.onchange().subscribe(() => { // Execute the callback in the Angular zone, so change detection doesn't stop working. zone.run(() => { this.isOnline = this.appProvider.isOnline(); diff --git a/src/addon/mod/data/components/action/action.ts b/src/addon/mod/data/components/action/action.ts index 9e96c3d1aaf..b96dc078dc6 100644 --- a/src/addon/mod/data/components/action/action.ts +++ b/src/addon/mod/data/components/action/action.ts @@ -20,6 +20,7 @@ import { AddonModDataOfflineProvider } from '../../providers/offline'; import { CoreSitesProvider } from '@providers/sites'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreTagProvider } from '@core/tag/providers/tag'; /** * Component that displays a database action. @@ -41,13 +42,16 @@ export class AddonModDataActionComponent implements OnInit { rootUrl: string; url: string; userPicture: string; + tagsEnabled: boolean; constructor(protected injector: Injector, protected dataProvider: AddonModDataProvider, protected dataOffline: AddonModDataOfflineProvider, protected eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, protected userProvider: CoreUserProvider, private navCtrl: NavController, - protected linkHelper: CoreContentLinksHelperProvider, private dataHelper: AddonModDataHelperProvider) { + protected linkHelper: CoreContentLinksHelperProvider, private dataHelper: AddonModDataHelperProvider, + private tagProvider: CoreTagProvider) { this.rootUrl = sitesProvider.getCurrentSite().getURL(); this.siteId = sitesProvider.getCurrentSiteId(); + this.tagsEnabled = this.tagProvider.areTagsAvailableInSite(); } /** diff --git a/src/addon/mod/data/components/action/addon-mod-data-action.html b/src/addon/mod/data/components/action/addon-mod-data-action.html index 41a44e5fafc..b6c9e9924f3 100644 --- a/src/addon/mod/data/components/action/addon-mod-data-action.html +++ b/src/addon/mod/data/components/action/addon-mod-data-action.html @@ -32,3 +32,5 @@ {{entry.fullname}} + + diff --git a/src/addon/mod/data/components/components.module.ts b/src/addon/mod/data/components/components.module.ts index 3470ae8724e..ef12a46b350 100644 --- a/src/addon/mod/data/components/components.module.ts +++ b/src/addon/mod/data/components/components.module.ts @@ -25,6 +25,7 @@ import { AddonModDataFieldPluginComponent } from './field-plugin/field-plugin'; import { AddonModDataActionComponent } from './action/action'; import { CoreCompileHtmlComponentModule } from '@core/compile/components/compile-html/compile-html.module'; import { CoreCommentsComponentsModule } from '@core/comments/components/components.module'; +import { CoreTagComponentsModule } from '@core/tag/components/components.module'; @NgModule({ declarations: [ @@ -41,7 +42,8 @@ import { CoreCommentsComponentsModule } from '@core/comments/components/componen CorePipesModule, CoreCourseComponentsModule, CoreCompileHtmlComponentModule, - CoreCommentsComponentsModule + CoreCommentsComponentsModule, + CoreTagComponentsModule ], providers: [ ], diff --git a/src/addon/mod/data/components/index/index.ts b/src/addon/mod/data/components/index/index.ts index 37a806f4321..bc346384205 100644 --- a/src/addon/mod/data/components/index/index.ts +++ b/src/addon/mod/data/components/index/index.ts @@ -85,7 +85,6 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp private prefetchHandler: AddonModDataPrefetchHandler, private timeUtils: CoreTimeUtilsProvider, private groupsProvider: CoreGroupsProvider, - private commentsProvider: CoreCommentsProvider, private modalCtrl: ModalController, private utils: CoreUtilsProvider, protected navCtrl: NavController) { @@ -152,8 +151,12 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp promises.push(this.dataProvider.invalidateDatabaseAccessInformationData(this.data.id)); promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.data.coursemodule)); promises.push(this.dataProvider.invalidateEntriesData(this.data.id)); + if (this.hasComments) { - promises.push(this.commentsProvider.invalidateCommentsByInstance('module', this.data.coursemodule)); + this.eventsProvider.trigger(CoreCommentsProvider.REFRESH_COMMENTS_EVENT, { + contextLevel: 'module', + instanceId: this.data.coursemodule + }, this.sitesProvider.getCurrentSiteId()); } } @@ -192,6 +195,7 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp return this.dataProvider.getDatabase(this.courseId, this.module.id).then((data) => { this.data = data; + this.hasComments = data.comments; this.description = data.intro || data.description; this.dataRetrieved.emit(data); @@ -226,16 +230,9 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp canSearch = true; canAdd = accessData.canaddentry; - return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule, accessData.canmanageentries) - .then((groupInfo) => { + return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule).then((groupInfo) => { this.groupInfo = groupInfo; - - // Check selected group is accessible. - if (groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { - if (!groupInfo.groups.some((group) => this.selectedGroup == group.id)) { - this.selectedGroup = groupInfo.groups[0].id; - } - } + this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo); }); }).then(() => { return this.dataProvider.getFields(this.data.id).then((fields) => { @@ -265,7 +262,6 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp * @return {Promise} Resolved then done. */ protected fetchEntriesData(): Promise { - this.hasComments = false; return this.dataProvider.getDatabaseAccessInformation(this.data.id, this.selectedGroup).then((accessData) => { // Update values for current group. @@ -299,14 +295,14 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp if (!this.isEmpty) { this.entries = entries.offlineEntries.concat(entries.entries); - let entriesHTML = this.data.listtemplateheader || ''; + let entriesHTML = this.dataHelper.getTemplate(this.data, 'listtemplateheader', this.fieldsArray); // Get first entry from the whole list. if (!this.search.searching || !this.firstEntry) { this.firstEntry = this.entries[0].id; } - const template = this.data.listtemplate || this.dataHelper.getDefaultTemplate('list', this.fieldsArray); + const template = this.dataHelper.getTemplate(this.data, 'listtemplate', this.fieldsArray); const entriesById = {}; this.entries.forEach((entry, index) => { @@ -318,7 +314,7 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp entriesHTML += this.dataHelper.displayShowFields(template, this.fieldsArray, entry, offset, 'list', actions); }); - entriesHTML += this.data.listtemplatefooter || ''; + entriesHTML += this.dataHelper.getTemplate(this.data, 'listtemplatefooter', this.fieldsArray); this.entriesRendered = entriesHTML; diff --git a/src/addon/mod/data/data.module.ts b/src/addon/mod/data/data.module.ts index 9babfa364bf..158865aecd3 100644 --- a/src/addon/mod/data/data.module.ts +++ b/src/addon/mod/data/data.module.ts @@ -33,6 +33,8 @@ import { AddonModDataSyncCronHandler } from './providers/sync-cron-handler'; import { AddonModDataOfflineProvider } from './providers/offline'; import { AddonModDataFieldsDelegate } from './providers/fields-delegate'; import { AddonModDataDefaultFieldHandler } from './providers/default-field-handler'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; +import { AddonModDataTagAreaHandler } from './providers/tag-area-handler'; import { AddonModDataFieldModule } from './fields/field.module'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -67,7 +69,8 @@ export const ADDON_MOD_DATA_PROVIDERS: any[] = [ AddonModDataEditLinkHandler, AddonModDataListLinkHandler, AddonModDataSyncCronHandler, - AddonModDataDefaultFieldHandler + AddonModDataDefaultFieldHandler, + AddonModDataTagAreaHandler ] }) export class AddonModDataModule { @@ -77,7 +80,8 @@ export class AddonModDataModule { cronDelegate: CoreCronDelegate, syncHandler: AddonModDataSyncCronHandler, updateManager: CoreUpdateManagerProvider, approveLinkHandler: AddonModDataApproveLinkHandler, deleteLinkHandler: AddonModDataDeleteLinkHandler, showLinkHandler: AddonModDataShowLinkHandler, editLinkHandler: AddonModDataEditLinkHandler, - listLinkHandler: AddonModDataListLinkHandler) { + listLinkHandler: AddonModDataListLinkHandler, tagAreaDelegate: CoreTagAreaDelegate, + tagAreaHandler: AddonModDataTagAreaHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -88,6 +92,7 @@ export class AddonModDataModule { contentLinksDelegate.registerHandler(editLinkHandler); contentLinksDelegate.registerHandler(listLinkHandler); cronDelegate.register(syncHandler); + tagAreaDelegate.registerHandler(tagAreaHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTableMigration({ diff --git a/src/addon/mod/data/fields/date/component/date.ts b/src/addon/mod/data/fields/date/component/date.ts index 5b17bf4a5ee..c846032a85b 100644 --- a/src/addon/mod/data/fields/date/component/date.ts +++ b/src/addon/mod/data/fields/date/component/date.ts @@ -42,19 +42,19 @@ export class AddonModDataFieldDateComponent extends AddonModDataFieldPluginCompo let val; - // Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them. - this.format = this.timeUtils.convertPHPToMoment(this.translate.instant('core.strftimedatefullshort')) - .replace(/[\[\]]/g, ''); + // Calculate format to use. + this.format = this.timeUtils.fixFormatForDatetime(this.timeUtils.convertPHPToMoment( + this.translate.instant('core.strftimedate'))); if (this.mode == 'search') { this.addControl('f_' + this.field.id + '_z'); val = this.search['f_' + this.field.id + '_y'] ? new Date(this.search['f_' + this.field.id + '_y'] + '-' + this.search['f_' + this.field.id + '_m'] + '-' + this.search['f_' + this.field.id + '_d']) : new Date(); - this.search['f_' + this.field.id] = val.toISOString(); + this.search['f_' + this.field.id] = this.timeUtils.toDatetimeFormat(val.getTime()); } else { val = this.value && this.value.content ? new Date(parseInt(this.value.content, 10) * 1000) : new Date(); - val = val.toISOString(); + val = this.timeUtils.toDatetimeFormat(val.getTime()); } this.addControl('f_' + this.field.id, val); diff --git a/src/addon/mod/data/fields/date/providers/handler.ts b/src/addon/mod/data/fields/date/providers/handler.ts index f36d166b702..bf9d6507932 100644 --- a/src/addon/mod/data/fields/date/providers/handler.ts +++ b/src/addon/mod/data/fields/date/providers/handler.ts @@ -15,6 +15,7 @@ import { Injector, Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { AddonModDataFieldHandler } from '../../../providers/fields-delegate'; import { AddonModDataFieldDateComponent } from '../component/date'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; /** * Handler for date data field plugin. @@ -24,7 +25,7 @@ export class AddonModDataFieldDateHandler implements AddonModDataFieldHandler { name = 'AddonModDataFieldDateHandler'; type = 'date'; - constructor(private translate: TranslateService) { } + constructor(private translate: TranslateService, private timeUtils: CoreTimeUtilsProvider) { } /** * Return the Component to use to display the plugin data. @@ -129,7 +130,7 @@ export class AddonModDataFieldDateHandler implements AddonModDataFieldHandler { input = inputData[fieldName] && inputData[fieldName].substr(0, 10) || ''; originalFieldData = (originalFieldData && originalFieldData.content && - new Date(originalFieldData.content * 1000).toISOString().substr(0, 10)) || ''; + this.timeUtils.toDatetimeFormat(originalFieldData.content * 1000).substr(0, 10)) || ''; return input != originalFieldData; } diff --git a/src/addon/mod/data/fields/multimenu/providers/handler.ts b/src/addon/mod/data/fields/multimenu/providers/handler.ts index 7c98f1b8c03..0522bc30478 100644 --- a/src/addon/mod/data/fields/multimenu/providers/handler.ts +++ b/src/addon/mod/data/fields/multimenu/providers/handler.ts @@ -126,7 +126,7 @@ export class AddonModDataFieldMultimenuHandler implements AddonModDataFieldHandl * @return {any} Data overriden */ overrideData(originalContent: any, offlineContent: any, offlineFiles?: any): any { - originalContent.content = (offlineContent[''] && offlineContent[''].join('###')) || ''; + originalContent.content = (offlineContent[''] && offlineContent[''].join('##')) || ''; return originalContent; } diff --git a/src/addon/mod/data/lang/en.json b/src/addon/mod/data/lang/en.json index f358c48a870..219ec090c49 100644 --- a/src/addon/mod/data/lang/en.json +++ b/src/addon/mod/data/lang/en.json @@ -10,6 +10,7 @@ "confirmdeleterecord": "Are you sure you want to delete this entry?", "descending": "Descending", "disapprove": "Undo approval", + "edittagsnotsupported": "Sorry, editing tags is not supported by the app.", "emptyaddform": "You did not fill out any fields!", "entrieslefttoadd": "You must add {{$a.entriesleft}} more entry/entries in order to complete this activity", "entrieslefttoaddtoview": "You must add {{$a.entrieslefttoview}} more entry/entries before you can view other participants' entries.", @@ -34,8 +35,10 @@ "recorddisapproved": "Entry unapproved", "resetsettings": "Reset filters", "search": "Search", + "searchbytagsnotsupported": "Sorry, searching by tags is not supported by the app.", "selectedrequired": "All selected required", "single": "View single", + "tagarea_data_records": "Data records", "timeadded": "Time added", "timemodified": "Time modified", "usedate": "Include in search." diff --git a/src/addon/mod/data/pages/edit/edit.ts b/src/addon/mod/data/pages/edit/edit.ts index 35e74cd068f..babfa127947 100644 --- a/src/addon/mod/data/pages/edit/edit.ts +++ b/src/addon/mod/data/pages/edit/edit.ts @@ -28,6 +28,7 @@ import { AddonModDataHelperProvider } from '../../providers/helper'; import { AddonModDataOfflineProvider } from '../../providers/offline'; import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; import { AddonModDataComponentsModule } from '../../components/components.module'; +import { CoreTagProvider } from '@core/tag/providers/tag'; /** * Page that displays the view edit page. @@ -68,7 +69,8 @@ export class AddonModDataEditPage { protected courseProvider: CoreCourseProvider, protected dataProvider: AddonModDataProvider, protected dataOffline: AddonModDataOfflineProvider, protected dataHelper: AddonModDataHelperProvider, sitesProvider: CoreSitesProvider, protected navCtrl: NavController, protected translate: TranslateService, - protected eventsProvider: CoreEventsProvider, protected fileUploaderProvider: CoreFileUploaderProvider) { + protected eventsProvider: CoreEventsProvider, protected fileUploaderProvider: CoreFileUploaderProvider, + private tagProvider: CoreTagProvider) { this.module = params.get('module') || {}; this.entryId = params.get('entryId') || null; this.courseId = params.get('courseId'); @@ -131,16 +133,9 @@ export class AddonModDataEditPage { return this.dataProvider.getDatabaseAccessInformation(data.id); }).then((accessData) => { if (this.entryId) { - return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule, accessData.canmanageentries) - .then((groupInfo) => { + return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule).then((groupInfo) => { this.groupInfo = groupInfo; - - // Check selected group is accessible. - if (groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { - if (!groupInfo.groups.some((group) => this.selectedGroup == group.id)) { - this.selectedGroup = groupInfo.groups[0].id; - } - } + this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo); }); } }).then(() => { @@ -287,7 +282,7 @@ export class AddonModDataEditPage { let replace, render, - template = this.data.addtemplate || this.dataHelper.getDefaultTemplate('add', this.fieldsArray); + template = this.dataHelper.getTemplate(this.data, 'addtemplate', this.fieldsArray); // Replace the fields found on template. this.fieldsArray.forEach((field) => { @@ -309,6 +304,11 @@ export class AddonModDataEditPage { template = template.replace(replace, 'field_' + field.id); }); + // Editing tags is not supported. + replace = new RegExp('##tags##', 'gi'); + const message = '

{{ \'addon.mod_data.edittagsnotsupported\' | translate }}

'; + template = template.replace(replace, this.tagProvider.areTagsAvailableInSite() ? message : ''); + return template; } diff --git a/src/addon/mod/data/pages/entry/entry.ts b/src/addon/mod/data/pages/entry/entry.ts index 51e95ce9ce2..95f96d8fca4 100644 --- a/src/addon/mod/data/pages/entry/entry.ts +++ b/src/addon/mod/data/pages/entry/entry.ts @@ -27,6 +27,7 @@ import { AddonModDataSyncProvider } from '../../providers/sync'; import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; import { AddonModDataComponentsModule } from '../../components/components.module'; import { CoreCommentsProvider } from '@core/comments/providers/comments'; +import { CoreCommentsCommentsComponent } from '@core/comments/components/comments/comments'; /** * Page that displays the view entry page. @@ -38,6 +39,7 @@ import { CoreCommentsProvider } from '@core/comments/providers/comments'; }) export class AddonModDataEntryPage implements OnDestroy { @ViewChild(Content) content: Content; + @ViewChild(CoreCommentsCommentsComponent) comments: CoreCommentsCommentsComponent; protected module: any; protected entryId: number; @@ -149,21 +151,14 @@ export class AddonModDataEntryPage implements OnDestroy { }).then((accessData) => { this.access = accessData; - return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule, accessData.canmanageentries) - .then((groupInfo) => { + return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule).then((groupInfo) => { this.groupInfo = groupInfo; - - // Check selected group is accessible. - if (groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { - if (!groupInfo.groups.some((group) => this.selectedGroup == group.id)) { - this.selectedGroup = groupInfo.groups[0].id; - } - } + this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo); }); }).then(() => { const actions = this.dataHelper.getActions(this.data, this.access, this.entry); - const template = this.data.singletemplate || this.dataHelper.getDefaultTemplate('single', this.fieldsArray); + const template = this.dataHelper.getTemplate(this.data, 'singletemplate', this.fieldsArray); this.entryHtml = this.dataHelper.displayShowFields(template, this.fieldsArray, this.entry, this.offset, 'show', actions); this.showComments = actions.comments; @@ -221,6 +216,13 @@ export class AddonModDataEntryPage implements OnDestroy { promises.push(this.dataProvider.invalidateEntryData(this.data.id, this.entryId)); promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.data.coursemodule)); promises.push(this.dataProvider.invalidateEntriesData(this.data.id)); + + if (this.data.comments && this.entry && this.entry.id > 0 && this.commentsEnabled && this.comments) { + // Refresh comments. Don't add it to promises because we don't want the comments fetch to block the entry fetch. + this.comments.doRefresh().catch(() => { + // Ignore errors. + }); + } } return Promise.all(promises).finally(() => { diff --git a/src/addon/mod/data/pages/search/search.ts b/src/addon/mod/data/pages/search/search.ts index 4ca20b5d04e..e61153d3c9f 100644 --- a/src/addon/mod/data/pages/search/search.ts +++ b/src/addon/mod/data/pages/search/search.ts @@ -21,6 +21,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { AddonModDataComponentsModule } from '../../components/components.module'; import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; import { AddonModDataHelperProvider } from '../../providers/helper'; +import { CoreTagProvider } from '@core/tag/providers/tag'; /** * Page that displays the search modal. @@ -42,7 +43,8 @@ export class AddonModDataSearchPage { constructor(params: NavParams, private viewCtrl: ViewController, fb: FormBuilder, protected utils: CoreUtilsProvider, protected domUtils: CoreDomUtilsProvider, protected fieldsDelegate: AddonModDataFieldsDelegate, - protected textUtils: CoreTextUtilsProvider, protected dataHelper: AddonModDataHelperProvider) { + protected textUtils: CoreTextUtilsProvider, protected dataHelper: AddonModDataHelperProvider, + private tagProvider: CoreTagProvider) { this.search = params.get('search'); this.fields = params.get('fields'); this.data = params.get('data'); @@ -89,7 +91,7 @@ export class AddonModDataSearchPage { search: this.search.advanced }; - let template = this.data.asearchtemplate || this.dataHelper.getDefaultTemplate('asearch', this.fieldsArray), + let template = this.dataHelper.getTemplate(this.data, 'asearchtemplate', this.fieldsArray), replace, render; // Replace the fields found on template. @@ -117,9 +119,10 @@ export class AddonModDataSearchPage { [placeholder]="\'addon.mod_data.authorlastname\' | translate" formControlName="lastname">'; template = template.replace(replace, render); - // Tags are unsupported right now. + // Searching by tags is not supported. replace = new RegExp('##tags##', 'gi'); - template = template.replace(replace, ''); + const message = '

{{ \'addon.mod_data.searchbytagsnotsupported\' | translate }}

'; + template = template.replace(replace, this.tagProvider.areTagsAvailableInSite() ? message : ''); return template; } diff --git a/src/addon/mod/data/providers/helper.ts b/src/addon/mod/data/providers/helper.ts index b5aaadcec53..2938c6c7281 100644 --- a/src/addon/mod/data/providers/helper.ts +++ b/src/addon/mod/data/providers/helper.ts @@ -158,11 +158,12 @@ export class AddonModDataHelperProvider { * @param {any} entry Entry. * @param {number} offset Entry offset. * @param {string} mode Mode list or show. - * @param {AddonModDataOfflineAction[]} actions Actions that can be performed to the record. + * @param {{[name: string]: boolean}} actions Actions that can be performed to the record. * @return {string} Generated HTML. */ displayShowFields(template: string, fields: any[], entry: any, offset: number, mode: string, - actions: AddonModDataOfflineAction[]): string { + actions: {[name: string]: boolean}): string { + if (!template) { return ''; } @@ -337,7 +338,7 @@ export class AddonModDataHelperProvider { approved: !data.approval || data.manageapproved, canmanageentry: true, fullname: site.getInfo().fullname, - contents: [], + contents: {}, } }); } @@ -357,9 +358,9 @@ export class AddonModDataHelperProvider { * @param {any} database Database activity. * @param {any} accessInfo Access info to the activity. * @param {any} record Entry or record where the actions will be performed. - * @return {any} Keyed with the action names and boolean to evalute if it can or cannot be done. + * @return {{[name: string]: boolean}} Keyed with the action names and boolean to evalute if it can or cannot be done. */ - getActions(database: any, accessInfo: any, record: any): any { + getActions(database: any, accessInfo: any, record: any): {[name: string]: boolean} { return { more: true, moreurl: true, @@ -367,6 +368,7 @@ export class AddonModDataHelperProvider { userpicture: true, timeadded: true, timemodified: true, + tags: true, edit: record.canmanageentry && !record.deleted, // This already checks capabilities and readonly period. delete: record.canmanageentry, @@ -377,7 +379,6 @@ export class AddonModDataHelperProvider { comments: database.comments, // Unsupported actions. - tags: false, delcheck: false, export: false }; @@ -410,10 +411,14 @@ export class AddonModDataHelperProvider { * @param {any[]} fields List of database fields. * @return {string} Template HTML. */ - getDefaultTemplate( type: 'add' | 'list' | 'single' | 'asearch', fields: any[]): string { + getDefaultTemplate(type: string, fields: any[]): string { + if (type == 'listtemplateheader' || type == 'listtemplatefooter') { + return ''; + } + const html = []; - if (type == 'list') { + if (type == 'listtemplate') { html.push('##delcheck##
'); } @@ -432,7 +437,7 @@ export class AddonModDataHelperProvider { ); }); - if (type == 'list') { + if (type == 'listtemplate') { html.push( '', '', @@ -440,7 +445,7 @@ export class AddonModDataHelperProvider { '', '' ); - } else if (type == 'single') { + } else if (type == 'singletemplate') { html.push( '', '', @@ -448,7 +453,7 @@ export class AddonModDataHelperProvider { '', '' ); - } else if (type == 'asearch') { + } else if (type == 'asearchtemplate') { html.push( '', 'Author first name: ', @@ -467,7 +472,7 @@ export class AddonModDataHelperProvider { '
' ); - if (type == 'list') { + if (type == 'listtemplate') { html.push('
'); } @@ -583,6 +588,28 @@ export class AddonModDataHelperProvider { }); } + /** + * Returns the template of a certain type. + * + * @param {any} data Database object. + * @param {string} type Type of template. + * @param {any[]} fields List of database fields. + * @return {string} Template HTML. + */ + getTemplate(data: any, type: string, fields: any[]): string { + let template = data[type] || this.getDefaultTemplate(type, fields); + + // Try to fix syntax errors so the template can be parsed by Angular. + template = this.domUtils.fixHtml(template); + + // Add core-link directive to links. + template = template.replace(/]*href="[^>]*)>/ig, (match, attributes) => { + return ''; + }); + + return template; + } + /** * Check if data has been changed by the user. * diff --git a/src/addon/mod/data/providers/tag-area-handler.ts b/src/addon/mod/data/providers/tag-area-handler.ts new file mode 100644 index 00000000000..f7fb150984a --- /dev/null +++ b/src/addon/mod/data/providers/tag-area-handler.ts @@ -0,0 +1,58 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreTagHelperProvider } from '@core/tag/providers/helper'; +import { CoreTagFeedComponent } from '@core/tag/components/feed/feed'; +import { AddonModDataProvider } from './data'; + +/** + * Handler to support tags. + */ +@Injectable() +export class AddonModDataTagAreaHandler implements CoreTagAreaHandler { + name = 'AddonModDataTagAreaHandler'; + type = 'mod_data/data_records'; + + constructor(private tagHelper: CoreTagHelperProvider, private dataProvider: AddonModDataProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.dataProvider.isPluginEnabled(); + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + return this.tagHelper.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreTagFeedComponent; + } +} diff --git a/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html b/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html index bc403c9e012..8a70e2dfc55 100644 --- a/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html +++ b/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html @@ -45,7 +45,7 @@ - + {{ 'core.groupsseparate' | translate }} {{ 'core.groupsvisible' | translate }} diff --git a/src/addon/mod/feedback/components/index/index.ts b/src/addon/mod/feedback/components/index/index.ts index 953a8c8bd82..eefc27a7e92 100644 --- a/src/addon/mod/feedback/components/index/index.ts +++ b/src/addon/mod/feedback/components/index/index.ts @@ -195,14 +195,14 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity } return this.fetchFeedbackOverviewData(this.access); - }).then(() => { - // All data obtained, now fill the context menu. + }).finally(() => { + // Now fill the context menu. this.fillContextMenu(refresh); // Check if there are responses stored in offline. - return this.feedbackOffline.hasFeedbackOfflineData(this.feedback.id); - }).then((hasOffline) => { - this.hasOffline = hasOffline; + return this.feedbackOffline.hasFeedbackOfflineData(this.feedback.id).then((hasOffline) => { + this.hasOffline = hasOffline; + }); }); } @@ -269,7 +269,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity return this.groupsProvider.getActivityGroupInfo(cmId).then((groupInfo) => { this.groupInfo = groupInfo; - return this.setGroup(this.group); + return this.setGroup(this.groupsProvider.validateGroupId(this.group, groupInfo)); }); } diff --git a/src/addon/mod/feedback/pages/form/form.html b/src/addon/mod/feedback/pages/form/form.html index 2725d3525a6..9bf4f4d1a0a 100644 --- a/src/addon/mod/feedback/pages/form/form.html +++ b/src/addon/mod/feedback/pages/form/form.html @@ -30,7 +30,7 @@

{{ 'addon.mod_feedback.mode' | translate }}

-

{{ 'addon.mod_feedback.numberoutofrange' | translate }} [{{item.rangefrom}}, {{item.rangeto}}]

+

{{ 'addon.mod_feedback.numberoutofrange' | translate }} [{{item.rangefrom}}, {{item.rangeto}}]

diff --git a/src/addon/mod/feedback/pages/form/form.ts b/src/addon/mod/feedback/pages/form/form.ts index 65a21bec8ac..f1da26ca8b4 100644 --- a/src/addon/mod/feedback/pages/form/form.ts +++ b/src/addon/mod/feedback/pages/form/form.ts @@ -82,10 +82,10 @@ export class AddonModFeedbackFormPage implements OnDestroy { this.currentSite = sitesProvider.getCurrentSite(); // Refresh online status when changes. - this.onlineObserver = network.onchange().subscribe((online) => { + this.onlineObserver = network.onchange().subscribe(() => { // Execute the callback in the Angular zone, so change detection doesn't stop working. zone.run(() => { - this.offline = !online; + this.offline = !this.appProvider.isOnline(); }); }); } diff --git a/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts index 1ca3de97404..4f8c210b72b 100644 --- a/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts +++ b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts @@ -78,6 +78,7 @@ export class AddonModFeedbackNonRespondentsPage { return this.groupsProvider.getActivityGroupInfo(this.moduleId).then((groupInfo) => { this.groupInfo = groupInfo; + this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo); return this.loadGroupUsers(this.selectedGroup); }).catch((message) => { diff --git a/src/addon/mod/feedback/pages/respondents/respondents.ts b/src/addon/mod/feedback/pages/respondents/respondents.ts index e5d555231a3..4a4a669ea52 100644 --- a/src/addon/mod/feedback/pages/respondents/respondents.ts +++ b/src/addon/mod/feedback/pages/respondents/respondents.ts @@ -99,6 +99,7 @@ export class AddonModFeedbackRespondentsPage { return this.groupsProvider.getActivityGroupInfo(this.moduleId).then((groupInfo) => { this.groupInfo = groupInfo; + this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo); return this.loadGroupAttempts(this.selectedGroup); }).catch((message) => { diff --git a/src/addon/mod/forum/components/components.module.ts b/src/addon/mod/forum/components/components.module.ts index 0f3bf1b10d5..06cdabd01e2 100644 --- a/src/addon/mod/forum/components/components.module.ts +++ b/src/addon/mod/forum/components/components.module.ts @@ -21,6 +21,7 @@ import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { CoreRatingComponentsModule } from '@core/rating/components/components.module'; +import { CoreTagComponentsModule } from '@core/tag/components/components.module'; import { AddonModForumIndexComponent } from './index/index'; import { AddonModForumPostComponent } from './post/post'; @@ -37,7 +38,8 @@ import { AddonModForumPostComponent } from './post/post'; CoreDirectivesModule, CorePipesModule, CoreCourseComponentsModule, - CoreRatingComponentsModule + CoreRatingComponentsModule, + CoreTagComponentsModule ], providers: [ ], diff --git a/src/addon/mod/forum/components/index/addon-mod-forum-index.html b/src/addon/mod/forum/components/index/addon-mod-forum-index.html index 8a7dae44043..4c8be80bf33 100644 --- a/src/addon/mod/forum/components/index/addon-mod-forum-index.html +++ b/src/addon/mod/forum/components/index/addon-mod-forum-index.html @@ -31,17 +31,17 @@ {{ availabilityMessage }}
- - + + + -
- -
+
+ +
- @@ -95,9 +95,9 @@

- - + + diff --git a/src/addon/mod/forum/components/post/addon-mod-forum-post.html b/src/addon/mod/forum/components/post/addon-mod-forum-post.html index cb8ffc1b328..81107408cd5 100644 --- a/src/addon/mod/forum/components/post/addon-mod-forum-post.html +++ b/src/addon/mod/forum/components/post/addon-mod-forum-post.html @@ -1,6 +1,6 @@ - +

@@ -30,6 +30,10 @@

+ +
{{ 'core.tag.tags' | translate }}:
+ +
diff --git a/src/addon/mod/forum/components/post/post.ts b/src/addon/mod/forum/components/post/post.ts index 581c7eefe1c..d6a9ca54d1d 100644 --- a/src/addon/mod/forum/components/post/post.ts +++ b/src/addon/mod/forum/components/post/post.ts @@ -14,10 +14,9 @@ import { Component, Input, Output, Optional, EventEmitter, OnInit, OnDestroy } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { NavController, Content } from 'ionic-angular'; +import { Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; -import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreSyncProvider } from '@providers/sync'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -26,6 +25,7 @@ import { AddonModForumHelperProvider } from '../../providers/helper'; import { AddonModForumOfflineProvider } from '../../providers/offline'; import { AddonModForumSyncProvider } from '../../providers/sync'; import { CoreRatingInfo } from '@core/rating/providers/rating'; +import { CoreTagProvider } from '@core/tag/providers/tag'; /** * Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.). @@ -53,11 +53,11 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { uniqueId: string; advanced = false; // Display all form fields. + tagsEnabled: boolean; protected syncId: string; constructor( - private navCtrl: NavController, private uploaderProvider: CoreFileUploaderProvider, private syncProvider: CoreSyncProvider, private domUtils: CoreDomUtilsProvider, @@ -67,9 +67,10 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { private forumHelper: AddonModForumHelperProvider, private forumOffline: AddonModForumOfflineProvider, private forumSync: AddonModForumSyncProvider, - @Optional() private svComponent: CoreSplitViewComponent, + private tagProvider: CoreTagProvider, @Optional() private content: Content) { this.onPostChange = new EventEmitter(); + this.tagsEnabled = this.tagProvider.areTagsAvailableInSite(); } /** @@ -79,17 +80,6 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { this.uniqueId = this.post.id ? 'reply' + this.post.id : 'edit' + this.post.parent; } - /** - * Opens the profile of a user. - * - * @param {number} userId - */ - openUserProfile(userId: number): void { - // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav. - const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; - navCtrl.push('CoreUserProfilePage', {userId, courseId: this.courseId}); - } - /** * Set data to new post, clearing temporary files and updating original data. * diff --git a/src/addon/mod/forum/forum.module.ts b/src/addon/mod/forum/forum.module.ts index 215c343e647..b8463c714ea 100644 --- a/src/addon/mod/forum/forum.module.ts +++ b/src/addon/mod/forum/forum.module.ts @@ -18,6 +18,7 @@ import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; import { AddonModForumProvider } from './providers/forum'; import { AddonModForumOfflineProvider } from './providers/offline'; import { AddonModForumHelperProvider } from './providers/helper'; @@ -28,7 +29,9 @@ import { AddonModForumSyncCronHandler } from './providers/sync-cron-handler'; import { AddonModForumIndexLinkHandler } from './providers/index-link-handler'; import { AddonModForumDiscussionLinkHandler } from './providers/discussion-link-handler'; import { AddonModForumListLinkHandler } from './providers/list-link-handler'; +import { AddonModForumPostLinkHandler } from './providers/post-link-handler'; import { AddonModForumPushClickHandler } from './providers/push-click-handler'; +import { AddonModForumTagAreaHandler } from './providers/tag-area-handler'; import { AddonModForumComponentsModule } from './components/components.module'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -56,8 +59,10 @@ export const ADDON_MOD_FORUM_PROVIDERS: any[] = [ AddonModForumSyncCronHandler, AddonModForumIndexLinkHandler, AddonModForumListLinkHandler, + AddonModForumPostLinkHandler, AddonModForumDiscussionLinkHandler, - AddonModForumPushClickHandler + AddonModForumPushClickHandler, + AddonModForumTagAreaHandler ] }) export class AddonModForumModule { @@ -66,7 +71,9 @@ export class AddonModForumModule { cronDelegate: CoreCronDelegate, syncHandler: AddonModForumSyncCronHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModForumIndexLinkHandler, discussionHandler: AddonModForumDiscussionLinkHandler, updateManager: CoreUpdateManagerProvider, listLinkHandler: AddonModForumListLinkHandler, - pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonModForumPushClickHandler) { + pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonModForumPushClickHandler, + postLinkHandler: AddonModForumPostLinkHandler, tagAreaDelegate: CoreTagAreaDelegate, + tagAreaHandler: AddonModForumTagAreaHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -74,7 +81,9 @@ export class AddonModForumModule { linksDelegate.registerHandler(indexHandler); linksDelegate.registerHandler(discussionHandler); linksDelegate.registerHandler(listLinkHandler); + linksDelegate.registerHandler(postLinkHandler); pushNotificationsDelegate.registerClickHandler(pushClickHandler); + tagAreaDelegate.registerHandler(tagAreaHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTablesMigration([ diff --git a/src/addon/mod/forum/lang/en.json b/src/addon/mod/forum/lang/en.json index 93e72d2c2d7..dbfac5fd3be 100644 --- a/src/addon/mod/forum/lang/en.json +++ b/src/addon/mod/forum/lang/en.json @@ -50,6 +50,7 @@ "reply": "Reply", "replyplaceholder": "Write your reply...", "subject": "Subject", + "tagarea_forum_posts": "Forum posts", "thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", "thisforumisdue": "The due date for posting to this forum was {{$a}}.", "unlockdiscussion": "Unlock this discussion", diff --git a/src/addon/mod/forum/pages/discussion/discussion.ts b/src/addon/mod/forum/pages/discussion/discussion.ts index f8514dd5f9f..7c395fc576b 100644 --- a/src/addon/mod/forum/pages/discussion/discussion.ts +++ b/src/addon/mod/forum/pages/discussion/discussion.ts @@ -115,7 +115,7 @@ export class AddonModForumDiscussionPage implements OnDestroy { this.postId = navParams.get('postId'); this.isOnline = this.appProvider.isOnline(); - this.onlineObserver = network.onchange().subscribe((online) => { + this.onlineObserver = network.onchange().subscribe(() => { // Execute the callback in the Angular zone, so change detection doesn't stop working. zone.run(() => { this.isOnline = this.appProvider.isOnline(); @@ -354,6 +354,9 @@ export class AddonModForumDiscussionPage implements OnDestroy { return Promise.reject('Invalid forum discussion.'); } + this.defaultSubject = this.translate.instant('addon.mod_forum.re') + ' ' + this.discussion.subject; + this.replyData.subject = this.defaultSubject; + if (this.discussion.userfullname && this.discussion.parent == 0 && this.forum.type == 'single') { // Hide author for first post and type single. this.discussion.userfullname = null; diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.ts b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts index be2fb73833d..337966e1d20 100644 --- a/src/addon/mod/forum/pages/new-discussion/new-discussion.ts +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts @@ -136,14 +136,14 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { const promises = []; if (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS) { - promises.push(this.groupsProvider.getActivityAllowedGroups(this.cmId).then((forumGroups) => { + promises.push(this.groupsProvider.getActivityAllowedGroups(this.cmId).then((result) => { let promise; if (mode === CoreGroupsProvider.VISIBLEGROUPS) { // We need to check which of the returned groups the user can post to. - promise = this.validateVisibleGroups(forumGroups); + promise = this.validateVisibleGroups(result.groups); } else { // WS already filters groups, no need to do it ourselves. Add "All participants" if needed. - promise = this.addAllParticipantsOption(forumGroups, true); + promise = this.addAllParticipantsOption(result.groups, true); } return promise.then((forumGroups) => { diff --git a/src/addon/mod/forum/providers/forum.ts b/src/addon/mod/forum/providers/forum.ts index 3659e11efb9..6a8d7aa0fb3 100644 --- a/src/addon/mod/forum/providers/forum.ts +++ b/src/addon/mod/forum/providers/forum.ts @@ -254,13 +254,13 @@ export class AddonModForumProvider { formatDiscussionsGroups(cmId: number, discussions: any[]): Promise { discussions = this.utils.clone(discussions); - return this.groupsProvider.getActivityAllowedGroups(cmId).then((forumGroups) => { + return this.groupsProvider.getActivityAllowedGroups(cmId).then((result) => { const strAllParts = this.translate.instant('core.allparticipants'); const strAllGroups = this.translate.instant('core.allgroups'); // Turn groups into an object where each group is identified by id. const groups = {}; - forumGroups.forEach((fg) => { + result.groups.forEach((fg) => { groups[fg.id] = fg; }); diff --git a/src/addon/mod/forum/providers/index-link-handler.ts b/src/addon/mod/forum/providers/index-link-handler.ts index 4beb1226f9c..2b7b23e7c2b 100644 --- a/src/addon/mod/forum/providers/index-link-handler.ts +++ b/src/addon/mod/forum/providers/index-link-handler.ts @@ -16,6 +16,9 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { AddonModForumProvider } from './forum'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; /** * Handler to treat links to forum index. @@ -24,8 +27,12 @@ import { AddonModForumProvider } from './forum'; export class AddonModForumIndexLinkHandler extends CoreContentLinksModuleIndexHandler { name = 'AddonModForumIndexLinkHandler'; - constructor(courseHelper: CoreCourseHelperProvider, protected forumProvider: AddonModForumProvider) { + constructor(courseHelper: CoreCourseHelperProvider, protected forumProvider: AddonModForumProvider, + private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { super(courseHelper, 'AddonModForum', 'forum'); + + // Match the view.php URL with an id param. + this.pattern = new RegExp('\/mod\/forum\/view\.php.*([\&\?](f|id)=\\d+)'); } /** @@ -41,4 +48,36 @@ export class AddonModForumIndexLinkHandler extends CoreContentLinksModuleIndexHa isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { return true; } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + if (typeof params.f != 'undefined') { + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + forumId = parseInt(params.f, 10); + + this.courseProvider.getModuleBasicInfoByInstance(forumId, 'forum', siteId).then((module) => { + this.courseHelper.navigateToModule(parseInt(module.id, 10), siteId, module.course, undefined, + undefined, undefined, navCtrl); + }).finally(() => { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + }); + } + }]; + } + + return super.getActions(siteIds, url, params, courseId); + } } diff --git a/src/addon/mod/forum/providers/post-link-handler.ts b/src/addon/mod/forum/providers/post-link-handler.ts new file mode 100644 index 00000000000..73d691dc199 --- /dev/null +++ b/src/addon/mod/forum/providers/post-link-handler.ts @@ -0,0 +1,84 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Content links handler for forum new discussion. + * Match mod/forum/post.php?forum=6 with a valid data. + */ +@Injectable() +export class AddonModForumPostLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModForumPostLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModForum'; + pattern = /\/mod\/forum\/post\.php.*([\?\&](forum)=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private courseProvider: CoreCourseProvider, + private domUtils: CoreDomUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + forumId = parseInt(params.forum, 10); + + this.courseProvider.getModuleBasicInfoByInstance(forumId, 'forum', siteId).then((module) => { + const pageParams = { + courseId: module.course, + cmId: module.id, + forumId: module.instance, + timeCreated: 0, + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModForumNewDiscussionPage', pageParams, siteId); + }).finally(() => { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return typeof params.forum != 'undefined'; + } +} diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts index 6241dce1923..e698a4977a5 100644 --- a/src/addon/mod/forum/providers/prefetch-handler.ts +++ b/src/addon/mod/forum/providers/prefetch-handler.ts @@ -255,7 +255,7 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand } // Activity uses groups, prefetch allowed groups. - return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((groups) => { + return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((result) => { if (mode === CoreGroupsProvider.SEPARATEGROUPS) { // Groups are already filtered by WS. Prefetch canAddDiscussionToAll to determine if user can pin/attach. return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => { @@ -278,7 +278,7 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand // The user can't post to all groups, let's check which groups he can post to. const groupPromises = []; - groups.forEach((group) => { + result.groups.forEach((group) => { groupPromises.push(this.forumProvider.canAddDiscussion(forum.id, group.id).catch(() => { // Ignore errors. })); diff --git a/src/addon/mod/forum/providers/sync.ts b/src/addon/mod/forum/providers/sync.ts index 5ad34cbb600..fcfbc2743ec 100644 --- a/src/addon/mod/forum/providers/sync.ts +++ b/src/addon/mod/forum/providers/sync.ts @@ -228,8 +228,8 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { if (data.groupid == AddonModForumProvider.ALL_GROUPS) { // Fetch all group ids. groupsPromise = this.forumProvider.getForumById(data.courseid, data.forumid, siteId).then((forum) => { - return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((groups) => { - return groups.map((group) => group.id); + return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((result) => { + return result.groups.map((group) => group.id); }); }); } else { diff --git a/src/addon/mod/forum/providers/tag-area-handler.ts b/src/addon/mod/forum/providers/tag-area-handler.ts new file mode 100644 index 00000000000..02505b31c04 --- /dev/null +++ b/src/addon/mod/forum/providers/tag-area-handler.ts @@ -0,0 +1,57 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreTagHelperProvider } from '@core/tag/providers/helper'; +import { CoreTagFeedComponent } from '@core/tag/components/feed/feed'; + +/** + * Handler to support tags. + */ +@Injectable() +export class AddonModForumTagAreaHandler implements CoreTagAreaHandler { + name = 'AddonModForumTagAreaHandler'; + type = 'mod_forum/forum_posts'; + + constructor(private tagHelper: CoreTagHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + return this.tagHelper.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreTagFeedComponent; + } +} diff --git a/src/addon/mod/glossary/components/index/index.ts b/src/addon/mod/glossary/components/index/index.ts index 558eac5311b..ed3d807e930 100644 --- a/src/addon/mod/glossary/components/index/index.ts +++ b/src/addon/mod/glossary/components/index/index.ts @@ -269,7 +269,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity this.viewMode = 'cat'; this.fetchFunction = this.glossaryProvider.getEntriesByCategory; this.fetchInvalidate = this.glossaryProvider.invalidateEntriesByCategory; - this.fetchArguments = [this.glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATERGORIES]; + this.fetchArguments = [this.glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATEGORIES]; this.getDivider = (entry: any): string => entry.categoryname; this.showDivider = (entry?: any, previous?: any): boolean => { return !previous || this.getDivider(entry) != this.getDivider(previous); diff --git a/src/addon/mod/glossary/glossary.module.ts b/src/addon/mod/glossary/glossary.module.ts index ab114c54bc1..e3520fbe72a 100644 --- a/src/addon/mod/glossary/glossary.module.ts +++ b/src/addon/mod/glossary/glossary.module.ts @@ -17,6 +17,7 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; import { AddonModGlossaryProvider } from './providers/glossary'; import { AddonModGlossaryOfflineProvider } from './providers/offline'; import { AddonModGlossaryHelperProvider } from './providers/helper'; @@ -27,6 +28,8 @@ import { AddonModGlossarySyncCronHandler } from './providers/sync-cron-handler'; import { AddonModGlossaryIndexLinkHandler } from './providers/index-link-handler'; import { AddonModGlossaryEntryLinkHandler } from './providers/entry-link-handler'; import { AddonModGlossaryListLinkHandler } from './providers/list-link-handler'; +import { AddonModGlossaryEditLinkHandler } from './providers/edit-link-handler'; +import { AddonModGlossaryTagAreaHandler } from './providers/tag-area-handler'; import { AddonModGlossaryComponentsModule } from './components/components.module'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -54,7 +57,9 @@ export const ADDON_MOD_GLOSSARY_PROVIDERS: any[] = [ AddonModGlossarySyncCronHandler, AddonModGlossaryIndexLinkHandler, AddonModGlossaryEntryLinkHandler, - AddonModGlossaryListLinkHandler + AddonModGlossaryListLinkHandler, + AddonModGlossaryEditLinkHandler, + AddonModGlossaryTagAreaHandler ] }) export class AddonModGlossaryModule { @@ -62,7 +67,9 @@ export class AddonModGlossaryModule { prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModGlossaryPrefetchHandler, cronDelegate: CoreCronDelegate, syncHandler: AddonModGlossarySyncCronHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModGlossaryIndexLinkHandler, discussionHandler: AddonModGlossaryEntryLinkHandler, - updateManager: CoreUpdateManagerProvider, listLinkHandler: AddonModGlossaryListLinkHandler) { + updateManager: CoreUpdateManagerProvider, listLinkHandler: AddonModGlossaryListLinkHandler, + editLinkHandler: AddonModGlossaryEditLinkHandler, tagAreaDelegate: CoreTagAreaDelegate, + tagAreaHandler: AddonModGlossaryTagAreaHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -70,6 +77,8 @@ export class AddonModGlossaryModule { linksDelegate.registerHandler(indexHandler); linksDelegate.registerHandler(discussionHandler); linksDelegate.registerHandler(listLinkHandler); + linksDelegate.registerHandler(editLinkHandler); + tagAreaDelegate.registerHandler(tagAreaHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTableMigration({ diff --git a/src/addon/mod/glossary/lang/en.json b/src/addon/mod/glossary/lang/en.json index 18e5ff7bcb9..ba4329f33db 100644 --- a/src/addon/mod/glossary/lang/en.json +++ b/src/addon/mod/glossary/lang/en.json @@ -26,5 +26,6 @@ "linking": "Auto-linking", "modulenameplural": "Glossaries", "noentriesfound": "No entries were found.", - "searchquery": "Search query" + "searchquery": "Search query", + "tagarea_glossary_entries": "Glossary entries" } diff --git a/src/addon/mod/glossary/pages/edit/edit.html b/src/addon/mod/glossary/pages/edit/edit.html index f5c2381a0e7..5678fcba899 100644 --- a/src/addon/mod/glossary/pages/edit/edit.html +++ b/src/addon/mod/glossary/pages/edit/edit.html @@ -19,7 +19,7 @@ {{ 'addon.mod_glossary.categories' | translate }} - + {{ category.name }} diff --git a/src/addon/mod/glossary/pages/entry/entry.html b/src/addon/mod/glossary/pages/entry/entry.html index f34e46d2777..5954398ff4e 100644 --- a/src/addon/mod/glossary/pages/entry/entry.html +++ b/src/addon/mod/glossary/pages/entry/entry.html @@ -28,6 +28,10 @@

+ +
{{ 'core.tag.tags' | translate }}:
+ +

{{ 'addon.mod_glossary.entrypendingapproval' | translate }}

diff --git a/src/addon/mod/glossary/pages/entry/entry.module.ts b/src/addon/mod/glossary/pages/entry/entry.module.ts index cc69e9dc4cf..7309430914e 100644 --- a/src/addon/mod/glossary/pages/entry/entry.module.ts +++ b/src/addon/mod/glossary/pages/entry/entry.module.ts @@ -19,6 +19,7 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; import { CoreRatingComponentsModule } from '@core/rating/components/components.module'; +import { CoreTagComponentsModule } from '@core/tag/components/components.module'; import { AddonModGlossaryEntryPage } from './entry'; @NgModule({ @@ -31,7 +32,8 @@ import { AddonModGlossaryEntryPage } from './entry'; CorePipesModule, IonicPageModule.forChild(AddonModGlossaryEntryPage), TranslateModule.forChild(), - CoreRatingComponentsModule + CoreRatingComponentsModule, + CoreTagComponentsModule ], }) export class AddonModForumDiscussionPageModule {} diff --git a/src/addon/mod/glossary/pages/entry/entry.ts b/src/addon/mod/glossary/pages/entry/entry.ts index 7bbf4f0bf19..a5b38641d0c 100644 --- a/src/addon/mod/glossary/pages/entry/entry.ts +++ b/src/addon/mod/glossary/pages/entry/entry.ts @@ -16,6 +16,7 @@ import { Component } from '@angular/core'; import { IonicPage, NavParams } from 'ionic-angular'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreRatingInfo } from '@core/rating/providers/rating'; +import { CoreTagProvider } from '@core/tag/providers/tag'; import { AddonModGlossaryProvider } from '../../providers/glossary'; /** @@ -35,15 +36,18 @@ export class AddonModGlossaryEntryPage { showAuthor = false; showDate = false; ratingInfo: CoreRatingInfo; + tagsEnabled: boolean; protected courseId: number; protected entryId: number; constructor(navParams: NavParams, private domUtils: CoreDomUtilsProvider, - private glossaryProvider: AddonModGlossaryProvider) { + private glossaryProvider: AddonModGlossaryProvider, + private tagProvider: CoreTagProvider) { this.courseId = navParams.get('courseId'); this.entryId = navParams.get('entryId'); + this.tagsEnabled = this.tagProvider.areTagsAvailableInSite(); } /** diff --git a/src/addon/mod/glossary/providers/edit-link-handler.ts b/src/addon/mod/glossary/providers/edit-link-handler.ts new file mode 100644 index 00000000000..c86557aee79 --- /dev/null +++ b/src/addon/mod/glossary/providers/edit-link-handler.ts @@ -0,0 +1,88 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonModGlossaryProvider } from './glossary'; + +/** + * Content links handler for glossary new entry. + * Match mod/glossary/edit.php?cmid=6 with a valid data. + * Currently it only supports new entry. + */ +@Injectable() +export class AddonModGlossaryEditLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModGlossaryEditLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModGlossary'; + pattern = /\/mod\/glossary\/edit\.php.*([\?\&](cmid)=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private courseProvider: CoreCourseProvider, + private domUtils: CoreDomUtilsProvider, private glossaryProvider: AddonModGlossaryProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + cmId = parseInt(params.cmid, 10); + + this.courseProvider.getModuleBasicInfo(cmId, siteId).then((module) => { + return this.glossaryProvider.getGlossary(module.course, module.id).then((glossary) => { + const pageParams = { + courseId: module.course, + module: module, + glossary: glossary, + entry: null // It does not support entry editing. + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModGlossaryEditPage', pageParams, siteId); + }); + }).finally(() => { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return typeof params.cmid != 'undefined'; + } +} diff --git a/src/addon/mod/glossary/providers/entry-link-handler.ts b/src/addon/mod/glossary/providers/entry-link-handler.ts index 953aeb41a23..99acce7b731 100644 --- a/src/addon/mod/glossary/providers/entry-link-handler.ts +++ b/src/addon/mod/glossary/providers/entry-link-handler.ts @@ -27,7 +27,7 @@ import { AddonModGlossaryProvider } from './glossary'; export class AddonModGlossaryEntryLinkHandler extends CoreContentLinksHandlerBase { name = 'AddonModGlossaryEntryLinkHandler'; featureName = 'CoreCourseModuleDelegate_AddonModGlossary'; - pattern = /\/mod\/glossary\/showentry\.php.*([\&\?]eid=\d+)/; + pattern = /\/mod\/glossary\/(showentry|view)\.php.*([\&\?](eid|g|mode|hook)=\d+)/; constructor( private domUtils: CoreDomUtilsProvider, @@ -51,7 +51,13 @@ export class AddonModGlossaryEntryLinkHandler extends CoreContentLinksHandlerBas return [{ action: (siteId, navCtrl?): void => { const modal = this.domUtils.showModalLoading(); - const entryId = parseInt(params.eid, 10); + let entryId; + if (params.mode == 'entry') { + entryId = parseInt(params.hook, 10); + } else { + entryId = parseInt(params.eid, 10); + } + let promise; if (courseId) { diff --git a/src/addon/mod/glossary/providers/glossary.ts b/src/addon/mod/glossary/providers/glossary.ts index bd6862d688a..cd494187f3b 100644 --- a/src/addon/mod/glossary/providers/glossary.ts +++ b/src/addon/mod/glossary/providers/glossary.ts @@ -17,7 +17,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreSite } from '@classes/site'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; -import { CoreSitesProvider } from '@providers/sites'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; @@ -32,13 +32,40 @@ export class AddonModGlossaryProvider { static COMPONENT = 'mmaModGlossary'; static LIMIT_ENTRIES = 25; static LIMIT_CATEGORIES = 10; - static SHOW_ALL_CATERGORIES = 0; + static SHOW_ALL_CATEGORIES = 0; static SHOW_NOT_CATEGORISED = -1; static ADD_ENTRY_EVENT = 'addon_mod_glossary_add_entry'; protected ROOT_CACHE_KEY = 'mmaModGlossary:'; + // Variables for database. + static ENTRIES_TABLE = 'addon_mod_glossary_entry_glossaryid'; + protected siteSchema: CoreSiteSchema = { + name: 'AddonModGlossaryProvider', + version: 1, + tables: [ + { + name: AddonModGlossaryProvider.ENTRIES_TABLE, + columns: [ + { + name: 'entryid', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'glossaryid', + type: 'INTEGER', + }, + { + name: 'pagefrom', + type: 'INTEGER', + } + ] + } + ] + }; + constructor(private appProvider: CoreAppProvider, private sitesProvider: CoreSitesProvider, private filepoolProvider: CoreFilepoolProvider, @@ -46,7 +73,10 @@ export class AddonModGlossaryProvider { private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider, private glossaryOffline: AddonModGlossaryOfflineProvider, - private logHelper: CoreCourseLogHelperProvider) {} + private logHelper: CoreCourseLogHelperProvider) { + + this.sitesProvider.registerSiteSchema(this.siteSchema); + } /** * Get the course glossary cache key. @@ -118,12 +148,13 @@ export class AddonModGlossaryProvider { * @param {string} sort The direction of the order: ASC or DESC * @param {number} from Start returning records from here. * @param {number} limit Number of records to return. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {boolean} omitExpires True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} forceOffline True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Resolved with the entries. */ getEntriesByAuthor(glossaryId: number, letter: string, field: string, sort: string, from: number, limit: number, - forceCache: boolean, siteId?: string): Promise { + omitExpires: boolean, forceOffline: boolean, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { id: glossaryId, @@ -135,7 +166,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort), - omitExpires: forceCache, + omitExpires: omitExpires, + forceOffline: forceOffline, updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; @@ -165,16 +197,18 @@ export class AddonModGlossaryProvider { * Get entries by category. * * @param {number} glossaryId Glossary Id. - * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATERGORIES for all categories, or + * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATEGORIES for all categories, or * constant SHOW_NOT_CATEGORISED for uncategorised entries. * @param {number} from Start returning records from here. * @param {number} limit Number of records to return. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {boolean} omitExpires True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} forceOffline True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Resolved with the entries. */ - getEntriesByCategory(glossaryId: number, categoryId: number, from: number, limit: number, forceCache: boolean, - siteId?: string): Promise { + getEntriesByCategory(glossaryId: number, categoryId: number, from: number, limit: number, omitExpires: boolean, + forceOffline: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { const params = { id: glossaryId, @@ -184,7 +218,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByCategoryCacheKey(glossaryId, categoryId), - omitExpires: forceCache, + omitExpires: omitExpires, + forceOffline: forceOffline, updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; @@ -196,7 +231,7 @@ export class AddonModGlossaryProvider { * Invalidate cache of entries by category. * * @param {number} glossaryId Glossary Id. - * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATERGORIES for all categories, or + * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATEGORIES for all categories, or * constant SHOW_NOT_CATEGORISED for uncategorised entries. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Resolved when data is invalidated. @@ -213,7 +248,7 @@ export class AddonModGlossaryProvider { * Get the entries by category cache key. * * @param {number} glossaryId Glossary Id. - * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATERGORIES for all categories, or + * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATEGORIES for all categories, or * constant SHOW_NOT_CATEGORISED for uncategorised entries. * @return {string} Cache key. */ @@ -241,12 +276,14 @@ export class AddonModGlossaryProvider { * @param {string} sort The direction of the order. * @param {number} from Start returning records from here. * @param {number} limit Number of records to return. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {boolean} omitExpires True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} forceOffline True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Resolved with the entries. */ - getEntriesByDate(glossaryId: number, order: string, sort: string, from: number, limit: number, forceCache: boolean, - siteId?: string): Promise { + getEntriesByDate(glossaryId: number, order: string, sort: string, from: number, limit: number, omitExpires: boolean, + forceOffline: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { const params = { id: glossaryId, @@ -257,7 +294,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByDateCacheKey(glossaryId, order, sort), - omitExpires: forceCache, + omitExpires: omitExpires, + forceOffline: forceOffline, updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; @@ -300,12 +338,14 @@ export class AddonModGlossaryProvider { * @param {string} letter A letter, or a special keyword. * @param {number} from Start returning records from here. * @param {number} limit Number of records to return. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {boolean} omitExpires True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} forceOffline True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Resolved with the entries. + * @return {Promise} Resolved with the entries. */ - getEntriesByLetter(glossaryId: number, letter: string, from: number, limit: number, forceCache: boolean, siteId?: string): - Promise { + getEntriesByLetter(glossaryId: number, letter: string, from: number, limit: number, omitExpires: boolean, forceOffline: boolean, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { const params = { id: glossaryId, @@ -315,11 +355,22 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByLetterCacheKey(glossaryId, letter), - omitExpires: forceCache, + omitExpires: omitExpires, + forceOffline: forceOffline, updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; - return site.read('mod_glossary_get_entries_by_letter', params, preSets); + return site.read('mod_glossary_get_entries_by_letter', params, preSets).then((result) => { + + if (limit == AddonModGlossaryProvider.LIMIT_ENTRIES) { + // Store entries in background, don't block the user for this. + this.storeEntries(glossaryId, result.entries, from, site.getId()).catch(() => { + // Ignore errors. + }); + } + + return result; + }); }); } @@ -364,12 +415,13 @@ export class AddonModGlossaryProvider { * @param {string} sort The direction of the order. * @param {number} from Start returning records from here. * @param {number} limit Number of records to return. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {boolean} omitExpires True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} forceOffline True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Resolved with the entries. */ getEntriesBySearch(glossaryId: number, query: string, fullSearch: boolean, order: string, sort: string, from: number, - limit: number, forceCache: boolean, siteId?: string): Promise { + limit: number, omitExpires: boolean, forceOffline: boolean, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { id: glossaryId, @@ -382,7 +434,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort), - omitExpires: forceCache, + omitExpires: omitExpires, + forceOffline: forceOffline, updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; @@ -497,7 +550,7 @@ export class AddonModGlossaryProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the entry. */ - getEntry(entryId: number, siteId?: string): Promise<{entry: any, ratinginfo: CoreRatingInfo}> { + getEntry(entryId: number, siteId?: string): Promise<{entry: any, ratinginfo: CoreRatingInfo, from?: number}> { return this.sitesProvider.getSite(siteId).then((site) => { const params = { id: entryId @@ -513,6 +566,74 @@ export class AddonModGlossaryProvider { } else { return Promise.reject(null); } + }).catch((error) => { + // Entry not found. Search it in the list of entries. + let glossaryId; + + const searchEntry = (from: number, loadNext: boolean): Promise => { + // Get the entries from this "page" and check if the entry we're looking for is in it. + return this.getEntriesByLetter(glossaryId, 'ALL', from, AddonModGlossaryProvider.LIMIT_ENTRIES, false, true, + siteId).then((result) => { + + for (let i = 0; i < result.entries.length; i++) { + const entry = result.entries[i]; + if (entry.id == entryId) { + // Entry found, return it. + return { + entry: entry, + from: from + }; + } + } + + const nextFrom = from + result.entries.length; + if (nextFrom < result.count && loadNext) { + // Get the next "page". + return searchEntry(nextFrom, true); + } + + // No more pages and the entry wasn't found. Reject. + return Promise.reject(null); + }); + }; + + return this.getStoredDataForEntry(entryId, site.getId()).then((data) => { + glossaryId = data.glossaryId; + + if (typeof data.from != 'undefined') { + return searchEntry(data.from, false).catch(() => { + // Entry not found in that page. Search all pages. + return searchEntry(0, true); + }); + } + + // Page not specified, search all pages. + return searchEntry(0, true); + }).catch(() => { + return Promise.reject(error); + }); + }); + }); + } + + /** + * Get a glossary ID and the "from" of a given entry. + * + * @param {number} entryId Entry ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the glossary ID and the "from". + */ + getStoredDataForEntry(entryId: number, siteId?: string): Promise<{glossaryId: number, from: number}> { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + entryid: entryId + }; + + return site.getDb().getRecord(AddonModGlossaryProvider.ENTRIES_TABLE, conditions).then((record) => { + return { + glossaryId: record.glossaryid, + from: record.pagefrom + }; }); }); } @@ -524,19 +645,21 @@ export class AddonModGlossaryProvider { * @param {any[]} fetchArguments Arguments to call the fetching. * @param {number} [limitFrom=0] Number of entries already fetched, so fetch will be done from this number. * @param {number} [limitNum] Number of records to return. Defaults to LIMIT_ENTRIES. - * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [omitExpires=false] True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} [forceOffline=false] True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the response. */ fetchEntries(fetchFunction: Function, fetchArguments: any[], limitFrom: number = 0, limitNum?: number, - forceCache: boolean = false, siteId?: string): Promise { + omitExpires: boolean = false, forceOffline: boolean = false, siteId?: string): Promise { limitNum = limitNum || AddonModGlossaryProvider.LIMIT_ENTRIES; siteId = siteId || this.sitesProvider.getCurrentSiteId(); const args = fetchArguments.slice(); args.push(limitFrom); args.push(limitNum); - args.push(forceCache); + args.push(omitExpires); + args.push(forceOffline); args.push(siteId); return fetchFunction.apply(this, args); @@ -547,18 +670,21 @@ export class AddonModGlossaryProvider { * * @param {Function} fetchFunction Function to fetch. * @param {any[]} fetchArguments Arguments to call the fetching. - * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [omitExpires=false] True to always get the value from cache. If data isn't cached, it will call the WS. + * @param {boolean} [forceOffline=false] True to always get the value from cache. If data isn't cached, it won't call the WS. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with all entrries. */ - fetchAllEntries(fetchFunction: Function, fetchArguments: any[], forceCache: boolean = false, siteId?: string): Promise { + fetchAllEntries(fetchFunction: Function, fetchArguments: any[], omitExpires: boolean = false, forceOffline: boolean = false, + siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const entries = []; const limitNum = AddonModGlossaryProvider.LIMIT_ENTRIES; const fetchMoreEntries = (): Promise => { - return this.fetchEntries(fetchFunction, fetchArguments, entries.length, limitNum, forceCache, siteId).then((result) => { + return this.fetchEntries(fetchFunction, fetchArguments, entries.length, limitNum, omitExpires, forceOffline, siteId) + .then((result) => { Array.prototype.push.apply(entries, result.entries); return entries.length < result.count ? fetchMoreEntries() : entries; @@ -633,7 +759,8 @@ export class AddonModGlossaryProvider { const promises = []; if (!onlyEntriesList) { - promises.push(this.fetchAllEntries(this.getEntriesByLetter, [glossary.id, 'ALL'], true, siteId).then((entries) => { + promises.push(this.fetchAllEntries(this.getEntriesByLetter, [glossary.id, 'ALL'], true, false, siteId) + .then((entries) => { return this.invalidateEntries(entries, siteId); })); } @@ -644,7 +771,7 @@ export class AddonModGlossaryProvider { promises.push(this.invalidateEntriesByLetter(glossary.id, 'ALL', siteId)); break; case 'cat': - promises.push(this.invalidateEntriesByCategory(glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATERGORIES, + promises.push(this.invalidateEntriesByCategory(glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, siteId)); break; case 'date': @@ -850,7 +977,7 @@ export class AddonModGlossaryProvider { // If we get here, there's no offline entry with this name, check online. // Get entries from the cache. - return this.fetchAllEntries(this.getEntriesByLetter, [glossaryId, 'ALL'], true, siteId).then((entries) => { + return this.fetchAllEntries(this.getEntriesByLetter, [glossaryId, 'ALL'], true, false, siteId).then((entries) => { // Check if there's any entry with the same concept. return entries.some((entry) => entry.concept == concept); }); @@ -906,4 +1033,44 @@ export class AddonModGlossaryProvider { return this.logHelper.logSingle('mod_glossary_view_entry', params, AddonModGlossaryProvider.COMPONENT, glossaryId, name, 'glossary', {entryid: entryId}, siteId); } + + /** + * Store several entries so we can determine their glossaryId in offline. + * + * @param {number} glossaryId Glossary ID the entries belongs to. + * @param {any[]} entries Entries. + * @param {number} from The "page" the entries belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + protected storeEntries(glossaryId: number, entries: any[], from: number, siteId?: string): Promise { + const promises = []; + + entries.forEach((entry) => { + promises.push(this.storeEntryId(glossaryId, entry.id, from, siteId)); + }); + + return Promise.all(promises); + } + + /** + * Store an entry so we can determine its glossaryId in offline. + * + * @param {number} glossaryId Glossary ID the entry belongs to. + * @param {number} entryId Entry ID. + * @param {number} from The "page" the entry belongs to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + protected storeEntryId(glossaryId: number, entryId: number, from: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + entryid: entryId, + glossaryid: glossaryId, + pagefrom: from + }; + + return site.getDb().insertRecord(AddonModGlossaryProvider.ENTRIES_TABLE, entry); + }); + } } diff --git a/src/addon/mod/glossary/providers/prefetch-handler.ts b/src/addon/mod/glossary/providers/prefetch-handler.ts index ba5be62f39c..9235c4b54e5 100644 --- a/src/addon/mod/glossary/providers/prefetch-handler.ts +++ b/src/addon/mod/glossary/providers/prefetch-handler.ts @@ -139,17 +139,17 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH break; case 'cat': // Not implemented. promises.push(this.glossaryProvider.fetchAllEntries(this.glossaryProvider.getEntriesByCategory, - [glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATERGORIES], false, siteId)); + [glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATEGORIES], false, false, siteId)); break; case 'date': promises.push(this.glossaryProvider.fetchAllEntries(this.glossaryProvider.getEntriesByDate, - [glossary.id, 'CREATION', 'DESC'], false, siteId)); + [glossary.id, 'CREATION', 'DESC'], false, false, siteId)); promises.push(this.glossaryProvider.fetchAllEntries(this.glossaryProvider.getEntriesByDate, - [glossary.id, 'UPDATE', 'DESC'], false, siteId)); + [glossary.id, 'UPDATE', 'DESC'], false, false, siteId)); break; case 'author': promises.push(this.glossaryProvider.fetchAllEntries(this.glossaryProvider.getEntriesByAuthor, - [glossary.id, 'ALL', 'LASTNAME', 'ASC'], false, siteId)); + [glossary.id, 'ALL', 'LASTNAME', 'ASC'], false, false, siteId)); break; default: } @@ -157,13 +157,12 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH // Fetch all entries to get information from. promises.push(this.glossaryProvider.fetchAllEntries(this.glossaryProvider.getEntriesByLetter, - [glossary.id, 'ALL'], false, siteId).then((entries) => { + [glossary.id, 'ALL'], false, false, siteId).then((entries) => { const promises = []; const avatars = {}; // List of user avatars, preventing duplicates. entries.forEach((entry) => { - // Fetch individual entries. - promises.push(this.glossaryProvider.getEntry(entry.id, siteId)); + // Don't fetch individual entries, it's too many WS calls. if (entry.userpictureurl) { avatars[entry.userpictureurl] = true; @@ -180,6 +179,13 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH return Promise.all(promises); })); + // Get all categories. + promises.push(this.glossaryProvider.getAllCategories(glossary.id)); + + // Prefetch data for link handlers. + promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId)); + promises.push(this.courseProvider.getModuleBasicInfoByInstance(glossary.id, 'glossary', siteId)); + return Promise.all(promises); }); } diff --git a/src/addon/mod/glossary/providers/tag-area-handler.ts b/src/addon/mod/glossary/providers/tag-area-handler.ts new file mode 100644 index 00000000000..4c62f698dcb --- /dev/null +++ b/src/addon/mod/glossary/providers/tag-area-handler.ts @@ -0,0 +1,57 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreTagHelperProvider } from '@core/tag/providers/helper'; +import { CoreTagFeedComponent } from '@core/tag/components/feed/feed'; + +/** + * Handler to support tags. + */ +@Injectable() +export class AddonModGlossaryTagAreaHandler implements CoreTagAreaHandler { + name = 'AddonModGlossaryTagAreaHandler'; + type = 'mod_glossary/glossary_entries'; + + constructor(private tagHelper: CoreTagHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + return this.tagHelper.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreTagFeedComponent; + } +} diff --git a/src/addon/mod/imscp/components/components.module.ts b/src/addon/mod/imscp/components/components.module.ts index 259c6c729cd..37a1cbeb251 100644 --- a/src/addon/mod/imscp/components/components.module.ts +++ b/src/addon/mod/imscp/components/components.module.ts @@ -20,12 +20,10 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModImscpIndexComponent } from './index/index'; -import { AddonModImscpTocPopoverComponent } from './toc-popover/toc-popover'; @NgModule({ declarations: [ AddonModImscpIndexComponent, - AddonModImscpTocPopoverComponent, ], imports: [ CommonModule, @@ -38,12 +36,10 @@ import { AddonModImscpTocPopoverComponent } from './toc-popover/toc-popover'; providers: [ ], exports: [ - AddonModImscpIndexComponent, - AddonModImscpTocPopoverComponent + AddonModImscpIndexComponent ], entryComponents: [ - AddonModImscpIndexComponent, - AddonModImscpTocPopoverComponent + AddonModImscpIndexComponent ] }) export class AddonModImscpComponentsModule {} diff --git a/src/addon/mod/imscp/components/index/index.ts b/src/addon/mod/imscp/components/index/index.ts index 11e53d2b2e2..3b793da191d 100644 --- a/src/addon/mod/imscp/components/index/index.ts +++ b/src/addon/mod/imscp/components/index/index.ts @@ -13,13 +13,12 @@ // limitations under the License. import { Component, Injector } from '@angular/core'; -import { PopoverController } from 'ionic-angular'; +import { ModalController } from 'ionic-angular'; import { CoreAppProvider } from '@providers/app'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseModuleMainResourceComponent } from '@core/course/classes/main-resource-component'; import { AddonModImscpProvider } from '../../providers/imscp'; import { AddonModImscpPrefetchHandler } from '../../providers/prefetch-handler'; -import { AddonModImscpTocPopoverComponent } from '../../components/toc-popover/toc-popover'; /** * Component that displays a IMSCP. @@ -40,7 +39,7 @@ export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceCom nextItem = ''; constructor(injector: Injector, private imscpProvider: AddonModImscpProvider, private courseProvider: CoreCourseProvider, - private appProvider: CoreAppProvider, private popoverCtrl: PopoverController, + private appProvider: CoreAppProvider, private modalCtrl: ModalController, private imscpPrefetch: AddonModImscpPrefetchHandler) { super(injector); } @@ -148,17 +147,23 @@ export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceCom * @param {MouseEvent} event Event. */ showToc(event: MouseEvent): void { - const popover = this.popoverCtrl.create(AddonModImscpTocPopoverComponent, { items: this.items }); - - popover.onDidDismiss((itemId) => { - if (!itemId) { - // Not valid, probably a category. - return; + // Create the toc modal. + const modal = this.modalCtrl.create('AddonModImscpTocPage', { + items: this.items, + selected: this.currentItem + }, { cssClass: 'core-modal-lateral', + showBackdrop: true, + enableBackdropDismiss: true, + enterAnimation: 'core-modal-lateral-transition', + leaveAnimation: 'core-modal-lateral-transition' }); + + modal.onDidDismiss((itemId) => { + if (itemId) { + this.loadItem(itemId); } - this.loadItem(itemId); }); - popover.present({ + modal.present({ ev: event }); } diff --git a/src/addon/mod/imscp/components/toc-popover/addon-mod-imscp-toc-popover.html b/src/addon/mod/imscp/components/toc-popover/addon-mod-imscp-toc-popover.html deleted file mode 100644 index 6bda8d59448..00000000000 --- a/src/addon/mod/imscp/components/toc-popover/addon-mod-imscp-toc-popover.html +++ /dev/null @@ -1,5 +0,0 @@ - -
- {{item.title}} - - diff --git a/src/addon/mod/imscp/lang/en.json b/src/addon/mod/imscp/lang/en.json index 4abb95089a5..441eea59866 100644 --- a/src/addon/mod/imscp/lang/en.json +++ b/src/addon/mod/imscp/lang/en.json @@ -1,5 +1,6 @@ { "deploymenterror": "Content package error!", "modulenameplural": "IMS content packages", - "showmoduledescription": "Show description" + "showmoduledescription": "Show description", + "toc": "TOC" } \ No newline at end of file diff --git a/src/addon/mod/imscp/pages/toc/toc.html b/src/addon/mod/imscp/pages/toc/toc.html new file mode 100644 index 00000000000..035a5d6a067 --- /dev/null +++ b/src/addon/mod/imscp/pages/toc/toc.html @@ -0,0 +1,19 @@ + + + {{ 'addon.mod_imscp.toc' | translate }} + + + + + + + + diff --git a/src/addon/mod/imscp/pages/toc/toc.module.ts b/src/addon/mod/imscp/pages/toc/toc.module.ts new file mode 100644 index 00000000000..28f970142eb --- /dev/null +++ b/src/addon/mod/imscp/pages/toc/toc.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModImscpTocPage } from './toc'; + +@NgModule({ + declarations: [ + AddonModImscpTocPage, + ], + imports: [ + CoreDirectivesModule, + IonicPageModule.forChild(AddonModImscpTocPage), + TranslateModule.forChild() + ], +}) +export class AddonModImscpTocPageModule {} diff --git a/src/addon/mod/imscp/components/toc-popover/toc-popover.ts b/src/addon/mod/imscp/pages/toc/toc.ts similarity index 73% rename from src/addon/mod/imscp/components/toc-popover/toc-popover.ts rename to src/addon/mod/imscp/pages/toc/toc.ts index 115c1241f22..1880e4813ec 100644 --- a/src/addon/mod/imscp/components/toc-popover/toc-popover.ts +++ b/src/addon/mod/imscp/pages/toc/toc.ts @@ -13,20 +13,23 @@ // limitations under the License. import { Component } from '@angular/core'; -import { NavParams, ViewController } from 'ionic-angular'; +import { IonicPage, NavParams, ViewController } from 'ionic-angular'; /** - * Component to display the TOC of a IMSCP. + * Modal to display the TOC of a imscp. */ +@IonicPage({ segment: 'addon-mod-imscp-toc-modal' }) @Component({ - selector: 'addon-mod-imscp-toc-popover', - templateUrl: 'addon-mod-imscp-toc-popover.html' + selector: 'page-addon-mod-imscp-toc', + templateUrl: 'toc.html' }) -export class AddonModImscpTocPopoverComponent { +export class AddonModImscpTocPage { items = []; + selected: string; constructor(navParams: NavParams, private viewCtrl: ViewController) { this.items = navParams.get('items') || []; + this.selected = navParams.get('selected'); } /** @@ -47,4 +50,11 @@ export class AddonModImscpTocPopoverComponent { getNumberForPadding(n: number): number[] { return new Array(n); } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } } diff --git a/src/addon/mod/label/label.scss b/src/addon/mod/label/label.scss index 9f6514ba6cc..46d2a860030 100644 --- a/src/addon/mod/label/label.scss +++ b/src/addon/mod/label/label.scss @@ -1,4 +1,4 @@ -a.core-course-module-handler.addon-mod-label-handler { +.item.core-course-module-handler.addon-mod-label-handler { align-items: center; &:hover { @@ -6,14 +6,14 @@ a.core-course-module-handler.addon-mod-label-handler { } } -.md a.core-course-module-handler.addon-mod-label-handler .item-inner { +.md .item.core-course-module-handler.addon-mod-label-handler .item-inner { padding-bottom: $item-md-padding-bottom; } -.ios a.core-course-module-handler.addon-mod-label-handler .item-inner { +.ios .item.core-course-module-handler.addon-mod-label-handler .item-inner { padding-bottom: $item-ios-padding-bottom; } -.wp a.core-course-module-handler.addon-mod-label-handler .item-inner { +.wp .item.core-course-module-handler.addon-mod-label-handler .item-inner { padding-bottom: $item-wp-padding-bottom; } diff --git a/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html index 55cc163c85d..655de63019e 100644 --- a/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html @@ -16,7 +16,7 @@ - + diff --git a/src/addon/mod/lesson/components/index/index.ts b/src/addon/mod/lesson/components/index/index.ts index 8a0acdba1c3..b179e79937b 100644 --- a/src/addon/mod/lesson/components/index/index.ts +++ b/src/addon/mod/lesson/components/index/index.ts @@ -223,7 +223,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo return this.groupsProvider.getActivityGroupInfo(this.module.id).then((groupInfo) => { this.groupInfo = groupInfo; - return this.setGroup(this.group || 0); + return this.setGroup(this.groupsProvider.validateGroupId(this.group, groupInfo)); }).finally(() => { this.reportLoaded = true; }); @@ -384,10 +384,19 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo }); } + /** + * First tab selected. + */ + indexSelected(): void { + this.selectedTab = 0; + } + /** * Reports tab selected. */ reportsSelected(): void { + this.selectedTab = 1; + if (!this.groupInfo) { this.fetchReportData().catch((error) => { this.domUtils.showErrorModalDefault(error, 'Error getting report.'); diff --git a/src/addon/mod/lesson/pages/player/player.scss b/src/addon/mod/lesson/pages/player/player.scss index e1ac00eb4fc..cb4a4caf9c9 100644 --- a/src/addon/mod/lesson/pages/player/player.scss +++ b/src/addon/mod/lesson/pages/player/player.scss @@ -11,7 +11,6 @@ ion-app.app-root page-addon-mod-lesson-player { } .addon-mod_lesson-pagebuttons .button-block { - contain: content; height: 100%; display: flex; flex-direction: column; diff --git a/src/addon/mod/lesson/pages/player/player.ts b/src/addon/mod/lesson/pages/player/player.ts index ab32eb9d670..a19c4e48fcc 100644 --- a/src/addon/mod/lesson/pages/player/player.ts +++ b/src/addon/mod/lesson/pages/player/player.ts @@ -25,6 +25,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { MoodleMobileApp } from '../../../../../app/app.component'; import { AddonModLessonProvider } from '../../providers/lesson'; import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline'; import { AddonModLessonSyncProvider } from '../../providers/lesson-sync'; @@ -85,7 +86,8 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy { protected lessonHelper: AddonModLessonHelperProvider, protected lessonSync: AddonModLessonSyncProvider, protected lessonOfflineProvider: AddonModLessonOfflineProvider, protected cdr: ChangeDetectorRef, modalCtrl: ModalController, protected navCtrl: NavController, protected appProvider: CoreAppProvider, - protected utils: CoreUtilsProvider, protected urlUtils: CoreUrlUtilsProvider, protected fb: FormBuilder) { + protected utils: CoreUtilsProvider, protected urlUtils: CoreUrlUtilsProvider, protected fb: FormBuilder, + protected mmApp: MoodleMobileApp) { this.lessonId = navParams.get('lessonId'); this.courseId = navParams.get('courseId'); @@ -145,6 +147,13 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy { return Promise.resolve(); } + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.mmApp.closeModal(); + } + /** * A button was clicked. * diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.ts b/src/addon/mod/lesson/pages/user-retake/user-retake.ts index 8a69c73ded2..394f781761a 100644 --- a/src/addon/mod/lesson/pages/user-retake/user-retake.ts +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.ts @@ -19,6 +19,7 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModLessonProvider } from '../../providers/lesson'; import { AddonModLessonHelperProvider } from '../../providers/helper'; @@ -44,11 +45,13 @@ export class AddonModLessonUserRetakePage implements OnInit { protected lessonId: number; // The lesson ID the retake belongs to. protected userId: number; // User ID to see the retakes. protected retakeNumber: number; // Number of the initial retake to see. + protected previousSelectedRetake: number; // To be able to detect the previous selected retake when it has changed. constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, protected textUtils: CoreTextUtilsProvider, protected translate: TranslateService, protected domUtils: CoreDomUtilsProvider, protected userProvider: CoreUserProvider, protected timeUtils: CoreTimeUtilsProvider, - protected lessonProvider: AddonModLessonProvider, protected lessonHelper: AddonModLessonHelperProvider) { + protected lessonProvider: AddonModLessonProvider, protected lessonHelper: AddonModLessonHelperProvider, + protected utils: CoreUtilsProvider) { this.lessonId = navParams.get('lessonId'); this.courseId = navParams.get('courseId'); @@ -75,7 +78,8 @@ export class AddonModLessonUserRetakePage implements OnInit { this.loaded = false; this.setRetake(retakeNumber).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'Error getting attempt.'); + this.selectedRetake = this.previousSelectedRetake; + this.domUtils.showErrorModal(this.utils.addDataNotDownloadedError(error, 'Error getting attempt.')); }).finally(() => { this.loaded = true; }); @@ -128,7 +132,7 @@ export class AddonModLessonUserRetakePage implements OnInit { student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2); student.attempts.forEach((retake) => { - if (this.retakeNumber == retake.try) { + if (!this.selectedRetake && this.retakeNumber == retake.try) { // The retake specified as parameter exists. Use it. this.selectedRetake = this.retakeNumber; } @@ -223,6 +227,7 @@ export class AddonModLessonUserRetakePage implements OnInit { } this.retake = data; + this.previousSelectedRetake = this.selectedRetake; }); } } diff --git a/src/addon/mod/lesson/providers/grade-link-handler.ts b/src/addon/mod/lesson/providers/grade-link-handler.ts index f71c50586c6..f76eb8b52ed 100644 --- a/src/addon/mod/lesson/providers/grade-link-handler.ts +++ b/src/addon/mod/lesson/providers/grade-link-handler.ts @@ -70,7 +70,7 @@ export class AddonModLessonGradeLinkHandler extends CoreContentLinksModuleGradeH this.linkHelper.goInSite(navCtrl, 'AddonModLessonUserRetakePage', pageParams, siteId); } else { // User cannot view the report, go to lesson index. - this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section); + this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section, undefined, undefined, navCtrl); } }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); diff --git a/src/addon/mod/lesson/providers/index-link-handler.ts b/src/addon/mod/lesson/providers/index-link-handler.ts index 244c55fcd5b..16d77cd6e40 100644 --- a/src/addon/mod/lesson/providers/index-link-handler.ts +++ b/src/addon/mod/lesson/providers/index-link-handler.ts @@ -19,6 +19,7 @@ import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { AddonModLessonProvider } from './lesson'; +import { NavController } from 'ionic-angular'; /** * Handler to treat links to lesson index. @@ -51,9 +52,10 @@ export class AddonModLessonIndexLinkHandler extends CoreContentLinksModuleIndexH /* Ignore the pageid param. If we open the lesson player with a certain page and the user hasn't started the lesson, an error is thrown: could not find lesson_timer records. */ if (params.userpassword) { - this.navigateToModuleWithPassword(parseInt(params.id, 10), courseId, params.userpassword, siteId); + this.navigateToModuleWithPassword(parseInt(params.id, 10), courseId, params.userpassword, siteId, navCtrl); } else { - this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId); + this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, + undefined, undefined, undefined, navCtrl); } } }]; @@ -80,9 +82,11 @@ export class AddonModLessonIndexLinkHandler extends CoreContentLinksModuleIndexH * @param {number} courseId Course ID. * @param {string} password Password. * @param {string} siteId Site ID. + * @param {NavController} navCtrl Navigation controller. * @return {Promise} Promise resolved when navigated. */ - protected navigateToModuleWithPassword(moduleId: number, courseId: number, password: string, siteId: string): Promise { + protected navigateToModuleWithPassword(moduleId: number, courseId: number, password: string, siteId: string, + navCtrl?: NavController): Promise { const modal = this.domUtils.showModalLoading(); // Get the module. @@ -93,11 +97,12 @@ export class AddonModLessonIndexLinkHandler extends CoreContentLinksModuleIndexH return this.lessonProvider.storePassword(parseInt(module.instance, 10), password, siteId).catch(() => { // Ignore errors. }).then(() => { - return this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section); + return this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section, + undefined, undefined, navCtrl); }); }).catch(() => { // Error, go to index page. - return this.courseHelper.navigateToModule(moduleId, siteId, courseId); + return this.courseHelper.navigateToModule(moduleId, siteId, courseId, undefined, undefined, undefined, navCtrl); }).finally(() => { modal.dismiss(); }); diff --git a/src/addon/mod/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts index a594a7493b7..bbd7666c66c 100644 --- a/src/addon/mod/lesson/providers/prefetch-handler.ts +++ b/src/addon/mod/lesson/providers/prefetch-handler.ts @@ -366,11 +366,10 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan if (accessInfo.canviewreports) { // Prefetch reports data. - promises.push(this.groupsProvider.getActivityAllowedGroupsIfEnabled(module.id, undefined, siteId, true) - .then((groups) => { + promises.push(this.groupsProvider.getActivityGroupInfo(module.id, false, undefined, siteId, true).then((info) => { const subPromises = []; - groups.forEach((group) => { + info.groups.forEach((group) => { subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, group.id, false, true, siteId)); }); diff --git a/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html b/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html index bb204717c70..7f7cb5b7444 100644 --- a/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html +++ b/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html @@ -90,7 +90,7 @@

{{ 'addon.mod_quiz.summaryofattempts' | translate }}

{{ 'addon.mod_quiz.noquestions' | translate }}

- +

{{ 'addon.mod_quiz.errorquestionsnotsupported' | translate }}

{{ type }}

@@ -109,6 +109,13 @@

{{ 'addon.mod_quiz.summaryofattempts' | translate }}

{{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + +

{{ 'addon.mod_quiz.canattemptbutnotsubmit' | translate }}

+

{{ 'addon.mod_quiz.warningquestionsnotsupported' | translate }}

+

{{ type }}

+
+ @@ -35,8 +38,20 @@

{{user.fullname}}

{{note.userfullname}}

-

{{note.lastmodified | coreDateDayOrTime}}

-

{{ 'core.notsent' | translate }}

+

+ {{note.lastmodified | coreDateDayOrTime}} +

+

+ {{ 'core.notsent' | translate }}

+

+ {{ 'core.deletedoffline' | translate }} +

+ +
diff --git a/src/addon/notes/components/list/list.ts b/src/addon/notes/components/list/list.ts index 0150edbfd01..cf6f54afa39 100644 --- a/src/addon/notes/components/list/list.ts +++ b/src/addon/notes/components/list/list.ts @@ -14,12 +14,15 @@ import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Content, ModalController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUserProvider } from '@core/user/providers/user'; +import { coreSlideInOut } from '@classes/animations'; import { AddonNotesProvider } from '../../providers/notes'; +import { AddonNotesOfflineProvider } from '../../providers/notes-offline'; import { AddonNotesSyncProvider } from '../../providers/notes-sync'; /** @@ -28,6 +31,7 @@ import { AddonNotesSyncProvider } from '../../providers/notes-sync'; @Component({ selector: 'addon-notes-list', templateUrl: 'addon-notes-list.html', + animations: [coreSlideInOut] }) export class AddonNotesListComponent implements OnInit, OnDestroy { @Input() courseId: number; @@ -44,11 +48,15 @@ export class AddonNotesListComponent implements OnInit, OnDestroy { hasOffline = false; notesLoaded = false; user: any; + showDelete = false; + canDeleteNotes = false; + currentUserId: number; constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, private modalCtrl: ModalController, private notesProvider: AddonNotesProvider, private notesSync: AddonNotesSyncProvider, - private userProvider: CoreUserProvider) { + private userProvider: CoreUserProvider, private translate: TranslateService, + private notesOffline: AddonNotesOfflineProvider) { // Refresh data if notes are synchronized automatically. this.syncObserver = eventsProvider.on(AddonNotesSyncProvider.AUTO_SYNCED, (data) => { if (data.courseId == this.courseId) { @@ -64,6 +72,8 @@ export class AddonNotesListComponent implements OnInit, OnDestroy { this.fetchNotes(false); } }, sitesProvider.getCurrentSiteId()); + + this.currentUserId = sitesProvider.getCurrentSiteUserId(); } /** @@ -93,24 +103,35 @@ export class AddonNotesListComponent implements OnInit, OnDestroy { return this.notesProvider.getNotes(this.courseId, this.userId).then((notes) => { notes = notes[this.type + 'notes'] || []; - this.hasOffline = notes.some((note) => note.offline); + return this.notesProvider.setOfflineDeletedNotes(notes, this.courseId).then((notes) => { - if (this.userId) { - this.notes = notes; + this.hasOffline = notes.some((note) => note.offline || note.deleted); - // Get the user profile to retrieve the user image. - return this.userProvider.getProfile(this.userId, this.courseId, true).then((user) => { - this.user = user; - }); - } else { - return this.notesProvider.getNotesUserData(notes, this.courseId).then((notes) => { + if (this.userId) { this.notes = notes; - }); - } + + // Get the user profile to retrieve the user image. + return this.userProvider.getProfile(this.userId, this.courseId, true).then((user) => { + this.user = user; + }); + } else { + return this.notesProvider.getNotesUserData(notes, this.courseId).then((notes) => { + this.notes = notes; + }); + } + }); }); }).catch((message) => { this.domUtils.showErrorModal(message); }).finally(() => { + let canDelete = this.notes && this.notes.length > 0; + if (canDelete && this.type == 'personal') { + canDelete = this.notes.find((note) => { + return note.usermodified == this.currentUserId; + }); + } + this.canDeleteNotes = canDelete; + this.notesLoaded = true; this.refreshIcon = 'refresh'; this.syncIcon = 'sync'; @@ -151,6 +172,7 @@ export class AddonNotesListComponent implements OnInit, OnDestroy { /** * Add a new Note to user and course. + * * @param {Event} e Event. */ addNote(e: Event): void { @@ -164,7 +186,7 @@ export class AddonNotesListComponent implements OnInit, OnDestroy { this.notesLoaded = false; } - this.refreshNotes(true); + this.refreshNotes(false); } else if (data && data.type && data.type != this.type) { this.type = data.type; this.typeChanged(); @@ -173,6 +195,53 @@ export class AddonNotesListComponent implements OnInit, OnDestroy { modal.present(); } + /** + * Delete a note. + * + * @param {Event} e Click event. + * @param {any} note Note to delete. + */ + deleteNote(e: Event, note: any): void { + e.preventDefault(); + e.stopPropagation(); + + this.domUtils.showConfirm(this.translate.instant('addon.notes.deleteconfirm')).then(() => { + this.notesProvider.deleteNote(note, this.courseId).then(() => { + this.showDelete = false; + + this.refreshNotes(false); + + this.domUtils.showToast('addon.notes.eventnotedeleted', true, 3000); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Delete note failed.'); + }); + }).catch(() => { + // User cancelled, nothing to do. + }); + } + + /** + * Restore a note. + * + * @param {Event} e Click event. + * @param {any} note Note to delete. + */ + undoDeleteNote(e: Event, note: any): void { + e.preventDefault(); + e.stopPropagation(); + + this.notesOffline.undoDeleteNote(note.id).then(() => { + this.refreshNotes(true); + }); + } + + /** + * Toggle delete. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + /** * Tries to synchronize course notes. * diff --git a/src/addon/notes/lang/en.json b/src/addon/notes/lang/en.json index 3317484cdd8..c8256d0c4b3 100644 --- a/src/addon/notes/lang/en.json +++ b/src/addon/notes/lang/en.json @@ -1,7 +1,9 @@ { "addnewnote": "Add a new note", "coursenotes": "Course notes", + "deleteconfirm": "Delete this note?", "eventnotecreated": "Note created", + "eventnotedeleted": "Note deleted", "nonotes": "There are no notes of this type yet", "note": "Note", "notes": "Notes", diff --git a/src/addon/notes/providers/notes-offline.ts b/src/addon/notes/providers/notes-offline.ts index 486fa0111d1..28bae97bfb5 100644 --- a/src/addon/notes/providers/notes-offline.ts +++ b/src/addon/notes/providers/notes-offline.ts @@ -26,9 +26,10 @@ export class AddonNotesOfflineProvider { // Variables for database. static NOTES_TABLE = 'addon_notes_offline_notes'; + static NOTES_DELETED_TABLE = 'addon_notes_deleted_offline_notes'; protected siteSchema: CoreSiteSchema = { name: 'AddonNotesOfflineProvider', - version: 1, + version: 2, tables: [ { name: AddonNotesOfflineProvider.NOTES_TABLE, @@ -63,6 +64,24 @@ export class AddonNotesOfflineProvider { } ], primaryKeys: ['userid', 'content', 'created'] + }, + { + name: AddonNotesOfflineProvider.NOTES_DELETED_TABLE, + columns: [ + { + name: 'noteid', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'deleted', + type: 'INTEGER' + }, + { + name: 'courseid', + type: 'INTEGER' + } + ] } ] }; @@ -73,7 +92,7 @@ export class AddonNotesOfflineProvider { } /** - * Delete a note. + * Delete an offline note. * * @param {number} userId User ID the note is about. * @param {string} content The note content. @@ -81,7 +100,7 @@ export class AddonNotesOfflineProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if deleted, rejected if failure. */ - deleteNote(userId: number, content: string, timecreated: number, siteId?: string): Promise { + deleteOfflineNote(userId: number, content: string, timecreated: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().deleteRecords(AddonNotesOfflineProvider.NOTES_TABLE, { userid: userId, @@ -91,6 +110,31 @@ export class AddonNotesOfflineProvider { }); } + /** + * Get all offline deleted notes. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with notes. + */ + getAllDeletedNotes(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonNotesOfflineProvider.NOTES_DELETED_TABLE); + }); + } + + /** + * Get course offline deleted notes. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with notes. + */ + getCourseDeletedNotes(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonNotesOfflineProvider.NOTES_DELETED_TABLE, {courseid: courseId}); + }); + } + /** * Get all offline notes. * @@ -246,4 +290,40 @@ export class AddonNotesOfflineProvider { }); }); } + + /** + * Delete a note offline to be sent later. + * + * @param {number} noteId Note ID. + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + deleteNote(noteId: number, courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const now = this.timeUtils.timestamp(); + const data = { + noteid: noteId, + courseid: courseId, + deleted: now + }; + + return site.getDb().insertRecord(AddonNotesOfflineProvider.NOTES_DELETED_TABLE, data).then(() => { + return data; + }); + }); + } + + /** + * Undo delete a note. + * + * @param {number} noteId Note ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + undoDeleteNote(noteId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(AddonNotesOfflineProvider.NOTES_DELETED_TABLE, { noteid: noteId }); + }); + } } diff --git a/src/addon/notes/providers/notes-sync.ts b/src/addon/notes/providers/notes-sync.ts index 77b7039c3e5..cebf92d73d8 100644 --- a/src/addon/notes/providers/notes-sync.ts +++ b/src/addon/notes/providers/notes-sync.ts @@ -63,18 +63,24 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider { * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ private syncAllNotesFunc(siteId: string, force: boolean): Promise { - return this.notesOffline.getAllNotes(siteId).then((notes) => { + const proms = []; + + proms.push(this.notesOffline.getAllNotes(siteId)); + proms.push(this.notesOffline.getAllDeletedNotes(siteId)); + + return Promise.all(proms).then((notesArray) => { // Get all the courses to be synced. - const courseIds = []; - notes.forEach((note) => { - if (courseIds.indexOf(note.courseid) == -1) { - courseIds.push(note.courseid); - } + const courseIds = {}; + notesArray.forEach((notes) => { + notes.forEach((note) => { + courseIds[note.courseid] = note.courseid; + }); }); - // Sync all courses. - const promises = courseIds.map((courseId) => { - const promise = force ? this.syncNotes(courseId, siteId) : this.syncNotesIfNeeded(courseId, siteId); + const promises = Object.keys(courseIds).map((courseId) => { + const cId = parseInt(courseIds[courseId], 10); + + const promise = force ? this.syncNotes(cId, siteId) : this.syncNotesIfNeeded(cId, siteId); return promise.then((warnings) => { if (typeof warnings != 'undefined') { @@ -124,9 +130,12 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider { this.logger.debug('Try to sync notes for course ' + courseId); const warnings = []; + const errors = []; + + const proms = []; // Get offline notes to be sent. - const syncPromise = this.notesOffline.getNotesForCourse(courseId, siteId).then((notes) => { + proms.push(this.notesOffline.getNotesForCourse(courseId, siteId).then((notes) => { if (!notes.length) { // Nothing to sync. return; @@ -157,12 +166,6 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider { } }); - // Fetch the notes from server to be sure they're up to date. - return this.notesProvider.invalidateNotes(courseId, undefined, siteId).then(() => { - return this.notesProvider.getNotes(courseId, undefined, false, true, siteId); - }).catch(() => { - // Ignore errors. - }); }).catch((error) => { if (this.utils.isWebServiceError(error)) { // It's a WebService error, this means the user cannot send notes. @@ -174,26 +177,69 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider { }).then(() => { // Notes were sent, delete them from local DB. const promises = notes.map((note) => { - return this.notesOffline.deleteNote(note.userid, note.content, note.created, siteId); + return this.notesOffline.deleteOfflineNote(note.userid, note.content, note.created, siteId); }); return Promise.all(promises); - }).then(() => { - if (errors && errors.length) { - // At least an error occurred, get course name and add errors to warnings array. - return this.coursesProvider.getUserCourse(courseId, true, siteId).catch(() => { - // Ignore errors. - return {}; - }).then((course) => { - errors.forEach((error) => { - warnings.push(this.translate.instant('addon.notes.warningnotenotsent', { - course: course.fullname ? course.fullname : courseId, - error: error - })); - }); - }); + }); + })); + + // Get offline notes to be sent. + proms.push(this.notesOffline.getCourseDeletedNotes(courseId, siteId).then((notes) => { + if (!notes.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + // Format the notes to be sent. + const notesToDelete = notes.map((note) => { + return note.noteid; + }); + + // Delete the notes. + return this.notesProvider.deleteNotesOnline(notesToDelete, courseId, siteId).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, this means the user cannot send notes. + errors.push(error); + } else { + // Not a WebService error, reject the synchronization to try again. + return Promise.reject(error); } + }).then(() => { + // Notes were sent, delete them from local DB. + const promises = notes.map((noteId) => { + return this.notesOffline.undoDeleteNote(noteId, siteId); + }); + + return Promise.all(promises); + }); + })); + + const syncPromise = Promise.all(proms).then(() => { + // Fetch the notes from server to be sure they're up to date. + return this.notesProvider.invalidateNotes(courseId, undefined, siteId).then(() => { + return this.notesProvider.getNotes(courseId, undefined, false, true, siteId); + }).catch(() => { + // Ignore errors. }); + }).then(() => { + if (errors && errors.length) { + // At least an error occurred, get course name and add errors to warnings array. + return this.coursesProvider.getUserCourse(courseId, true, siteId).catch(() => { + // Ignore errors. + return {}; + }).then((course) => { + errors.forEach((error) => { + warnings.push(this.translate.instant('addon.notes.warningnotenotsent', { + course: course.fullname ? course.fullname : courseId, + error: error + })); + }); + }); + } }).then(() => { // All done, return the warnings. return warnings; diff --git a/src/addon/notes/providers/notes.ts b/src/addon/notes/providers/notes.ts index 006a78093dd..82f095e4173 100644 --- a/src/addon/notes/providers/notes.ts +++ b/src/addon/notes/providers/notes.ts @@ -133,6 +133,72 @@ export class AddonNotesProvider { }); } + /** + * Delete a note. + * + * @param {any} note Note object to delete. + * @param {number} courseId Course ID where the note belongs. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that notes + * have been deleted, the resolve param can contain errors for notes not deleted. + */ + deleteNote(note: any, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (note.offline) { + return this.notesOffline.deleteOfflineNote(note.userid, note.content, note.created, siteId); + } + + // Convenience function to store the action to be synchronized later. + const storeOffline = (): Promise => { + return this.notesOffline.deleteNote(note.id, courseId, siteId).then(() => { + return false; + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the note. + return storeOffline(); + } + + // Send note to server. + return this.deleteNotesOnline([note.id], courseId, siteId).then(() => { + return true; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the note so don't store it. + return Promise.reject(error); + } + + // Error sending note, store it to retry later. + return storeOffline(); + }); + } + + /** + * Delete a note. It will fail if offline or cannot connect. + * + * @param {number[]} noteIds Note IDs to delete. + * @param {number} courseId Course ID where the note belongs. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that notes + * have been deleted, the resolve param can contain errors for notes not deleted. + */ + deleteNotesOnline(noteIds: number[], courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + notes: noteIds + }; + + return site.write('core_notes_delete_notes', data).then((response) => { + // A note was deleted, invalidate the course notes. + return this.invalidateNotes(courseId, undefined, siteId).catch(() => { + // Ignore errors. + }); + }); + }); + } + /** * Returns whether or not the notes plugin is enabled for a certain site. * @@ -267,6 +333,24 @@ export class AddonNotesProvider { }); } + /** + * Get offline deleted notes and set the state. + * + * @param {any[]} notes Array of notes. + * @param {number} courseId ID of the course the notes belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} [description] + */ + setOfflineDeletedNotes(notes: any[], courseId: number, siteId?: string): Promise { + return this.notesOffline.getCourseDeletedNotes(courseId, siteId).then((deletedNotes) => { + notes.forEach((note) => { + note.deleted = deletedNotes.some((n) => n.noteid == note.id); + }); + + return notes; + }); + } + /** * Get user data for notes since they only have userid. * diff --git a/src/addon/notes/providers/user-handler.ts b/src/addon/notes/providers/user-handler.ts index c7a37d061ac..cd9ce80152d 100644 --- a/src/addon/notes/providers/user-handler.ts +++ b/src/addon/notes/providers/user-handler.ts @@ -99,7 +99,7 @@ export class AddonNotesUserHandler implements CoreUserProfileHandler { action: (event, navCtrl, user, courseId): void => { event.preventDefault(); event.stopPropagation(); - // Always use redirect to make it the new history root (to avoid "loops" in history). + this.linkHelper.goInSite(navCtrl, 'AddonNotesListPage', { userId: user.id, courseId: courseId }); } }; diff --git a/src/addon/notifications/components/actions/actions.ts b/src/addon/notifications/components/actions/actions.ts index 2d56bc7688a..c2a6f23bb0d 100644 --- a/src/addon/notifications/components/actions/actions.ts +++ b/src/addon/notifications/components/actions/actions.ts @@ -31,7 +31,8 @@ export class AddonNotificationsActionsComponent implements OnInit { actions: CoreContentLinksAction[] = []; - constructor(private contentLinksDelegate: CoreContentLinksDelegate, private sitesProvider: CoreSitesProvider) {} + constructor(private contentLinksDelegate: CoreContentLinksDelegate, private sitesProvider: CoreSitesProvider, + public navCtrl: NavController) {} /** * Component being initialized. diff --git a/src/addon/notifications/components/actions/addon-notifications-actions.html b/src/addon/notifications/components/actions/addon-notifications-actions.html index 14425705d19..1f386603bf5 100644 --- a/src/addon/notifications/components/actions/addon-notifications-actions.html +++ b/src/addon/notifications/components/actions/addon-notifications-actions.html @@ -1,6 +1,6 @@ - diff --git a/src/addon/notifications/pages/list/list.ts b/src/addon/notifications/pages/list/list.ts index 3b8d1751ad1..1ab97b2be9f 100644 --- a/src/addon/notifications/pages/list/list.ts +++ b/src/addon/notifications/pages/list/list.ts @@ -41,8 +41,10 @@ export class AddonNotificationsListPage { canMarkAllNotificationsAsRead = false; loadingMarkAllNotificationsAsRead = false; + protected isCurrentView: boolean; protected cronObserver: CoreEventObserver; protected pushObserver: Subscription; + protected pendingRefresh = false; constructor(navParams: NavParams, private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, @@ -55,17 +57,24 @@ export class AddonNotificationsListPage { * View loaded. */ ionViewDidLoad(): void { - this.fetchNotifications().finally(() => { - this.notificationsLoaded = true; - }); + this.fetchNotifications(); - this.cronObserver = this.eventsProvider.on(AddonNotificationsProvider.READ_CRON_EVENT, () => this.refreshNotifications(), - this.sitesProvider.getCurrentSiteId()); + this.cronObserver = this.eventsProvider.on(AddonNotificationsProvider.READ_CRON_EVENT, () => { + if (this.isCurrentView) { + this.notificationsLoaded = false; + this.refreshNotifications(); + } + }, this.sitesProvider.getCurrentSiteId()); this.pushObserver = this.pushNotificationsDelegate.on('receive').subscribe((notification) => { // New notification received. If it's from current site, refresh the data. - if (this.utils.isTrueOrOne(notification.notif) && this.sitesProvider.isCurrentSite(notification.site)) { + if (this.isCurrentView && this.utils.isTrueOrOne(notification.notif) && + this.sitesProvider.isCurrentSite(notification.site)) { + + this.notificationsLoaded = false; this.refreshNotifications(); + } else if (!this.isCurrentView) { + this.pendingRefresh = true; } }); } @@ -93,6 +102,8 @@ export class AddonNotificationsListPage { }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true); this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + }).finally(() => { + this.notificationsLoaded = true; }); } @@ -110,9 +121,7 @@ export class AddonNotificationsListPage { // All marked as read, refresh the list. this.notificationsLoaded = false; - return this.refreshNotifications().finally(() => { - this.notificationsLoaded = true; - }); + return this.refreshNotifications(); }); } @@ -198,6 +207,27 @@ export class AddonNotificationsListPage { notification.mobiletext = this.textUtils.replaceNewLines(text, '
'); } + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + if (this.pendingRefresh) { + this.pendingRefresh = false; + this.notificationsLoaded = false; + + this.refreshNotifications(); + } + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + /** * Page destroyed. */ diff --git a/src/addon/storagemanager/providers/coursemenu-handler.ts b/src/addon/storagemanager/providers/coursemenu-handler.ts index e2aad3def21..69a578fe08d 100644 --- a/src/addon/storagemanager/providers/coursemenu-handler.ts +++ b/src/addon/storagemanager/providers/coursemenu-handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { CoreCourseOptionsMenuHandler, CoreCourseOptionsMenuHandlerData } from '@core/course/providers/options-delegate'; /** @@ -49,9 +49,11 @@ export class AddonStorageManagerCourseMenuHandler implements CoreCourseOptionsMe /** * Returns the data needed to render the handler. * + * @param {Injector} injector Injector. + * @param {any} course The course. * @return {CoreCourseOptionsMenuHandlerData} Data needed to render the handler. */ - getMenuDisplayData(): CoreCourseOptionsMenuHandlerData { + getMenuDisplayData(injector: Injector, course: any): CoreCourseOptionsMenuHandlerData { return { icon: 'cube', title: 'addon.storagemanager.managestorage', diff --git a/src/addon/userprofilefield/datetime/component/datetime.ts b/src/addon/userprofilefield/datetime/component/datetime.ts index c43a61bc022..8da732a6459 100644 --- a/src/addon/userprofilefield/datetime/component/datetime.ts +++ b/src/addon/userprofilefield/datetime/component/datetime.ts @@ -47,9 +47,9 @@ export class AddonUserProfileFieldDatetimeComponent implements OnInit { // Check if it's only date or it has time too. const hasTime = this.utils.isTrueOrOne(field.param3); - // Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them. - field.format = this.timeUtils.convertPHPToMoment(this.translate.instant('core.' + - (hasTime ? 'strftimedatetimeshort' : 'strftimedatefullshort'))).replace(/[\[\]]/g, ''); + // Calculate format to use. + field.format = this.timeUtils.fixFormatForDatetime(this.timeUtils.convertPHPToMoment( + this.translate.instant('core.' + (hasTime ? 'strftimedatetime' : 'strftimedate')))); // Check min value. if (field.param1) { diff --git a/src/addon/userprofilefield/datetime/providers/handler.ts b/src/addon/userprofilefield/datetime/providers/handler.ts index 65a7a6f6689..29d3d321ece 100644 --- a/src/addon/userprofilefield/datetime/providers/handler.ts +++ b/src/addon/userprofilefield/datetime/providers/handler.ts @@ -14,6 +14,7 @@ // limitations under the License. import { Injectable, Injector } from '@angular/core'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@core/user/providers/user-profile-field-delegate'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { AddonUserProfileFieldDatetimeComponent } from '../component/datetime'; /** @@ -24,7 +25,7 @@ export class AddonUserProfileFieldDatetimeHandler implements CoreUserProfileFiel name = 'AddonUserProfileFieldDatetime'; type = 'datetime'; - constructor() { + constructor(protected timeUtils: CoreTimeUtilsProvider) { // Nothing to do. } @@ -50,12 +51,10 @@ export class AddonUserProfileFieldDatetimeHandler implements CoreUserProfileFiel const name = 'profile_field_' + field.shortname; if (formValues[name]) { - const milliseconds = new Date(formValues[name]).getTime(); - return { type: 'datetime', name: 'profile_field_' + field.shortname, - value: Math.round(milliseconds / 1000) + value: this.timeUtils.convertToTimestamp(formValues[name]) }; } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1be81e471a8..7df4951b2a3 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -25,6 +25,7 @@ import { CoreCustomURLSchemesProvider } from '@providers/urlschemes'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { Keyboard } from '@ionic-native/keyboard'; import { ScreenOrientation } from '@ionic-native/screen-orientation'; +import { CoreLoginSitesPage } from '@core/login/pages/sites/sites'; @Component({ templateUrl: 'app.html' @@ -37,10 +38,10 @@ export class MoodleMobileApp implements OnInit { protected lastUrls = {}; protected lastInAppUrl: string; - constructor(private platform: Platform, logger: CoreLoggerProvider, keyboard: Keyboard, + constructor(private platform: Platform, logger: CoreLoggerProvider, keyboard: Keyboard, private app: IonicApp, private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, private zone: NgZone, private appProvider: CoreAppProvider, private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider, - private screenOrientation: ScreenOrientation, app: IonicApp, private urlSchemesProvider: CoreCustomURLSchemesProvider, + private screenOrientation: ScreenOrientation, private urlSchemesProvider: CoreCustomURLSchemesProvider, private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) { this.logger = logger.getInstance('AppComponent'); @@ -64,6 +65,11 @@ export class MoodleMobileApp implements OnInit { app.setElementClass('platform-windows', true); } } + + // Register back button action to allow closing modals before anything else. + this.appProvider.registerBackButtonAction(() => { + return this.closeModal(); + }, 2000); }); } @@ -74,7 +80,9 @@ export class MoodleMobileApp implements OnInit { ngOnInit(): void { this.eventsProvider.on(CoreEventsProvider.LOGOUT, () => { // Go to sites page when user is logged out. - this.appProvider.getRootNavController().setRoot('CoreLoginSitesPage'); + // Due to DeepLinker, we need to use the ViewCtrl instead of name. + // Otherwise some pages are re-created when they shouldn't. + this.appProvider.getRootNavController().setRoot(CoreLoginSitesPage); // Unload lang custom strings. this.langProvider.clearCustomStrings(); @@ -286,4 +294,21 @@ export class MoodleMobileApp implements OnInit { document.body.classList.remove(tempClass); }); } + + /** + * Close one modal if any. + * + * @return {boolean} True if one modal was present. + */ + closeModal(): boolean { + // Following function is hidden in Ionic Code, however there's no solution for that. + const portal = this.app._getActivePortal(); + if (portal) { + portal.pop(); + + return true; + } + + return false; + } } diff --git a/src/app/app.ios.scss b/src/app/app.ios.scss index 2655a22768c..8eeaf67a367 100644 --- a/src/app/app.ios.scss +++ b/src/app/app.ios.scss @@ -99,16 +99,22 @@ ion-app.app-root.ios { background-color: $checkbox-ios-background-color-off; } - // File Uploader - // In iOS the input is 1 level higher, so the styles are different. - .action-sheet-ios input.core-fileuploader-file-handler-input { - position: absolute; - @include position(null, 0, null, 0); - min-width: 100%; - min-height: $action-sheet-ios-button-min-height; - opacity: 0; - outline: none; - z-index: 100; - cursor: pointer; + + .action-sheet-ios { + .action-sheet-title { + font-size: 2rem; + } + // File Uploader + // In iOS the input is 1 level higher, so the styles are different. + input.core-fileuploader-file-handler-input { + position: absolute; + @include position(null, 0, null, 0); + min-width: 100%; + min-height: $action-sheet-ios-button-min-height; + opacity: 0; + outline: none; + z-index: 100; + cursor: pointer; + } } } \ No newline at end of file diff --git a/src/app/app.md.scss b/src/app/app.md.scss index 8a774558556..a97ce6e952b 100644 --- a/src/app/app.md.scss +++ b/src/app/app.md.scss @@ -70,18 +70,14 @@ ion-app.app-root.md { padding-top: 0; margin-top: $action-sheet-md-title-padding-top; } - .action-sheet-cancel { - color: $red; - } - .action-sheet-wrapper { - bottom: 0; - top: initial; - max-height: 50%; - height: 100%; - } - .action-sheet-selected { - color: $core-color; + @media (min-height: 500px) { + .action-sheet-wrapper { + bottom: 0; + top: initial; + max-height: 50%; + height: 100%; + } } } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4a174e54242..c843dce5429 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -15,7 +15,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { NgModule, COMPILER_OPTIONS } from '@angular/core'; -import { IonicApp, IonicModule, Platform, Content, ScrollEvent, Config } from 'ionic-angular'; +import { IonicApp, IonicModule, Platform, Content, ScrollEvent, Config, Refresher } from 'ionic-angular'; import { assert } from 'ionic-angular/util/util'; import { HttpModule } from '@angular/http'; import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; @@ -81,6 +81,7 @@ import { CoreQuestionModule } from '@core/question/question.module'; import { CoreCommentsModule } from '@core/comments/comments.module'; import { CoreBlockModule } from '@core/block/block.module'; import { CoreRatingModule } from '@core/rating/rating.module'; +import { CoreTagModule } from '@core/tag/tag.module'; // Addon modules. import { AddonBadgesModule } from '@addon/badges/badges.module'; @@ -91,12 +92,30 @@ import { AddonCourseCompletionModule } from '@addon/coursecompletion/coursecompl import { AddonUserProfileFieldModule } from '@addon/userprofilefield/userprofilefield.module'; import { AddonFilesModule } from '@addon/files/files.module'; import { AddonBlockActivityModulesModule } from '@addon/block/activitymodules/activitymodules.module'; +import { AddonBlockBadgesModule } from '@addon/block/badges/badges.module'; +import { AddonBlockBlogMenuModule } from '@addon/block/blogmenu/blogmenu.module'; +import { AddonBlockBlogTagsModule } from '@addon/block/blogtags/blogtags.module'; +import { AddonBlockBlogRecentModule } from '@addon/block/blogrecent/blogrecent.module'; +import { AddonBlockCalendarMonthModule } from '@addon/block/calendarmonth/calendarmonth.module'; +import { AddonBlockCalendarUpcomingModule } from '@addon/block/calendarupcoming/calendarupcoming.module'; +import { AddonBlockCommentsModule } from '@addon/block/comments/comments.module'; +import { AddonBlockCompletionStatusModule } from '@addon/block/completionstatus/completionstatus.module'; +import { AddonBlockGlossaryRandomModule } from '@addon/block/glossaryrandom/glossaryrandom.module'; +import { AddonBlockHtmlModule } from '@addon/block/html/html.module'; import { AddonBlockMyOverviewModule } from '@addon/block/myoverview/myoverview.module'; +import { AddonBlockNewsItemsModule } from '@addon/block/newsitems/newsitems.module'; +import { AddonBlockOnlineUsersModule } from '@addon/block/onlineusers/onlineusers.module'; +import { AddonBlockLearningPlansModule } from '@addon/block/learningplans/learningplans.module'; +import { AddonBlockPrivateFilesModule } from '@addon/block/privatefiles/privatefiles.module'; import { AddonBlockSiteMainMenuModule } from '@addon/block/sitemainmenu/sitemainmenu.module'; import { AddonBlockTimelineModule } from '@addon/block/timeline/timeline.module'; import { AddonBlockRecentlyAccessedCoursesModule } from '@addon/block/recentlyaccessedcourses/recentlyaccessedcourses.module'; import { AddonBlockRecentlyAccessedItemsModule } from '@addon/block/recentlyaccesseditems/recentlyaccesseditems.module'; +import { AddonBlockRecentActivityModule } from '@addon/block/recentactivity/recentactivity.module'; +import { AddonBlockRssClientModule } from '@addon/block/rssclient/rssclient.module'; import { AddonBlockStarredCoursesModule } from '@addon/block/starredcourses/starredcourses.module'; +import { AddonBlockSelfCompletionModule } from '@addon/block/selfcompletion/selfcompletion.module'; +import { AddonBlockTagsModule } from '@addon/block/tags/tags.module'; import { AddonModAssignModule } from '@addon/mod/assign/assign.module'; import { AddonModBookModule } from '@addon/mod/book/book.module'; import { AddonModChatModule } from '@addon/mod/chat/chat.module'; @@ -166,6 +185,8 @@ export const CORE_PROVIDERS: any[] = [ CoreCustomURLSchemesProvider ]; +export const WP_PROVIDER: any = null; + @NgModule({ declarations: [ MoodleMobileApp @@ -205,6 +226,7 @@ export const CORE_PROVIDERS: any[] = [ CoreBlockModule, CoreRatingModule, CorePushNotificationsModule, + CoreTagModule, AddonBadgesModule, AddonBlogModule, AddonCalendarModule, @@ -213,12 +235,30 @@ export const CORE_PROVIDERS: any[] = [ AddonUserProfileFieldModule, AddonFilesModule, AddonBlockActivityModulesModule, + AddonBlockBadgesModule, + AddonBlockBlogMenuModule, + AddonBlockBlogRecentModule, + AddonBlockBlogTagsModule, + AddonBlockCalendarMonthModule, + AddonBlockCalendarUpcomingModule, + AddonBlockCommentsModule, + AddonBlockCompletionStatusModule, + AddonBlockGlossaryRandomModule, + AddonBlockHtmlModule, + AddonBlockLearningPlansModule, AddonBlockMyOverviewModule, + AddonBlockNewsItemsModule, + AddonBlockOnlineUsersModule, + AddonBlockPrivateFilesModule, AddonBlockSiteMainMenuModule, AddonBlockTimelineModule, AddonBlockRecentlyAccessedCoursesModule, AddonBlockRecentlyAccessedItemsModule, + AddonBlockRecentActivityModule, + AddonBlockRssClientModule, AddonBlockStarredCoursesModule, + AddonBlockSelfCompletionModule, + AddonBlockTagsModule, AddonModAssignModule, AddonModBookModule, AddonModChatModule, @@ -333,6 +373,9 @@ export class AppModule { // Decorate ion-content. this.decorateIonContent(); + + // Patch ion-refresher. + this.patchIonRefresher(); } /** @@ -542,4 +585,24 @@ export class AppModule { this._orientationObs && this._orientationObs.off(); }; } + + /** + * Patch ion-refresher to fix video menus and possibly other fixed positioned elements. + */ + patchIonRefresher(): void { + /** + * Original code: https://github.com/ionic-team/ionic/blob/v3.9.3/src/components/refresher/refresher.ts#L468 + * Changed: translateZ(0px) is not added to the CSS transform. + */ + Refresher.prototype._setCss = function(y: number, duration: string, overflowVisible: boolean, delay: string): void { + this._appliedStyles = (y > 0); + + const content = this._content; + const Css = this._plt.Css; + content.setScrollElementStyle(Css.transform, ((y > 0) ? 'translateY(' + y + 'px)' : '')); + content.setScrollElementStyle(Css.transitionDuration, duration); + content.setScrollElementStyle(Css.transitionDelay, delay); + content.setScrollElementStyle('overflow', (overflowVisible ? 'hidden' : '')); + }; + } } diff --git a/src/app/app.scss b/src/app/app.scss index 41759e8eff1..594dccfe368 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -315,6 +315,11 @@ ion-app.app-root { } } + // All external files should be banned from copying it. + ion-avatar, img, audio, video, iframe, [core-external-content], [role="presentation"], [data-original-src], [src] { + user-select: none !important; + } + .core-media-adapt-width { max-width: 100%; } @@ -383,11 +388,11 @@ ion-app.app-root { ion-select { position: relative; // Ionic fix. Button can occupy all page if not. - color: $core-select-placeholder-color; + color: $core-select-color; align-self: start; .select-icon .select-icon-inner { - color: $core-select-placeholder-color; + color: $core-select-color; } &.select-disabled .select-icon .select-icon-inner { @@ -406,10 +411,18 @@ ion-app.app-root { } } + .item-label-stacked ion-select[multiple="true"] { + width: 100%; + } + + ion-select .select-placeholder { + color: $core-select-placeholder-color; + } + ion-select.core-button-select, .core-button-select { background-color: white; - color: $core-select-placeholder-color; + color: $core-select-color; white-space: normal; align-self: start; max-width: none; @@ -441,7 +454,7 @@ ion-app.app-root { } .select-icon .select-icon-inner { - color: $core-select-placeholder-color; + color: $core-select-color; } ion-icon:last-child { @@ -456,11 +469,6 @@ ion-app.app-root { } } - .col > .button-block { - contain: content; - } - - // File uploader. // ------------------------- .core-fileuploader-file-handler { @@ -599,6 +607,16 @@ ion-app.app-root { .action-sheet-group { overflow: auto; } + + .action-sheet-wrapper { + .action-sheet-button.action-sheet-cancel { + color: $core-action-sheet-cancel-color; + } + .action-sheet-selected { + color: $core-color; + } + } + .alert-message { overflow-y: auto; } @@ -649,13 +667,16 @@ ion-app.app-root { .toolbar img.core-bar-button-image, .toolbar .core-bar-button-image img { padding: 0; - width: 100%; - height: 100%; - max-width: $core-toolbar-button-image-width - 1; - max-height: $core-toolbar-button-image-width - 1; + width: $core-toolbar-button-image-width; + height: $core-toolbar-button-image-width; + max-width: $core-toolbar-button-image-width; border-radius: 50%; } + .header .toolbar-ios { + height: $toolbar-ios-height; + } + // Footer with auto height. .footer.footer-adjustable { height: auto; @@ -697,15 +718,21 @@ ion-app.app-root { .core-#{$color-name}-item.item-input { border-bottom: 0 !important; - &.item-md .item-inner { + &.item-md .item-inner, + &.item-md.item-input.ng-valid.item-input-has-value:not(.input-has-focus):not(.item-input-has-focus) .item-inner, + &.item-md.item-input.ng-valid.input-has-value:not(.input-has-focus):not(.item-input-has-focus) .item-inner { @include md-input-highlight($color-base); } - &.item-ios .item-inner { + &.item-ios .item-inner, + &.item-ios.item-input.ng-valid.item-input-has-value:not(.input-has-focus):not(.item-input-has-focus) .item-inner, + &.item-ios.item-input.ng-valid.input-has-value:not(.input-has-focus):not(.item-input-has-focus) .item-inner { @include ios-input-highlight($color-base); } - &.item-wp .item-inner { + &.item-wp .text-input, + &.item-wp.item-input.ng-valid.item-input-has-value:not(.input-has-focus):not(.item-input-has-focus) .text-input, + &.item-wp.item-input.ng-valid.input-has-value:not(.input-has-focus):not(.item-input-has-focus) .text-input { border-color: $color-base; } } @@ -999,6 +1026,11 @@ ion-app.app-root { ion-alert .alert-checkbox-button .alert-checkbox-label { white-space: normal; } + + ion-backdrop { + transition: opacity 100ms ease-in-out; + opacity: .1; + } } @each $color-name, $color-base, $color-contrast in get-colors($colors) { @@ -1069,21 +1101,19 @@ details summary { line-height: 28px; } -// Fix iframes in fullscreen mode. -// -// Ionic sets "contain: strict" to some elements. This enables paint containment, -// which changes behaviour of fixed positioned elements and seems to break iframes -// in fullscreen mode. See https://www.w3.org/TR/css-contain-1/#containment-paint -ion-app, -ion-nav, -ion-tab, -ion-tabs, -.app-root, -.ion-page, -ion-modal, -.modal-wrapper, -.split-pane { - contain: size layout style; +// Ionic sets the "contain" CSS property to some elements. This enables CSS +// containment, which changes how elements are positioned and sized, breaking +// fixed positioned elements, iframes in full screen mode, subtitle menus in +// videos and potentially more things. CSS containment is not supported in iOS +// and Android 4.4, so it can introduce inconsistencies across devices. +// See https://www.w3.org/TR/css-contain-1 +* { + contain: none !important; +} + +// Lower z-index for ion-item-divider so it is displayed below video menus. +ion-item-divider { + z-index: 2; // Ionic default is 100. } // Highlight text. @@ -1108,9 +1138,6 @@ ion-app.platform-desktop { .button-block[text-wrap] { height: auto; - // Changed from "strict" because the size depends on child elements. - contain: content; - // Add vertical padding, we cannot rely on a fixed height + centering like in normal buttons. .item-md & { padding-top: .5357em; @@ -1152,3 +1179,15 @@ ion-app.platform-desktop { min-height: $button-ios-small-height; } } + +// Make funnel icon have iOS look. +.ion-md-funnel::before { + content: "\f182"; +} + +// Fix icon size in lists, to prevent them scaling with text. +.item, .item-inner { + > ion-icon { + font-size: 28px; + } +} diff --git a/src/assets/img/splash.png b/src/assets/img/splash.png new file mode 100644 index 00000000000..e7889ccf91e Binary files /dev/null and b/src/assets/img/splash.png differ diff --git a/src/assets/img/splash_logo.png b/src/assets/img/splash_logo.png deleted file mode 100644 index 8c9fdfa9a89..00000000000 Binary files a/src/assets/img/splash_logo.png and /dev/null differ diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 7ed0d48128f..831223df192 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -27,6 +27,16 @@ "addon.badges.version": "Version", "addon.badges.warnexpired": "(This badge has expired!)", "addon.block_activitymodules.pluginname": "Activities", + "addon.block_badges.pluginname": "Latest badges", + "addon.block_blogmenu.pluginname": "Blog menu", + "addon.block_blogrecent.pluginname": "Recent blog entries", + "addon.block_blogtags.pluginname": "Blog tags", + "addon.block_calendarmonth.pluginname": "Calendar", + "addon.block_calendarupcoming.pluginname": " Upcoming events", + "addon.block_comments.pluginname": "Comments", + "addon.block_completionstatus.pluginname": "Course completion status", + "addon.block_glossaryrandom.pluginname": "Random glossary entry", + "addon.block_learningplans.pluginname": "Learning plans", "addon.block_myoverview.all": "All", "addon.block_myoverview.favourites": "Starred", "addon.block_myoverview.future": "Future", @@ -38,13 +48,20 @@ "addon.block_myoverview.past": "Past", "addon.block_myoverview.pluginname": "Course overview", "addon.block_myoverview.title": "Course name", + "addon.block_newsitems.pluginname": "Latest announcements", + "addon.block_onlineusers.pluginname": "Online users", + "addon.block_privatefiles.pluginname": "Private files", + "addon.block_recentactivity.pluginname": "Recent activity", "addon.block_recentlyaccessedcourses.nocourses": "No recent courses", "addon.block_recentlyaccessedcourses.pluginname": "Recently accessed courses", "addon.block_recentlyaccesseditems.noitems": "No recent items", "addon.block_recentlyaccesseditems.pluginname": "Recently accessed items", + "addon.block_rssclient.pluginname": "Remote RSS feeds", + "addon.block_selfcompletion.pluginname": "Self completion", "addon.block_sitemainmenu.pluginname": "Main menu", "addon.block_starredcourses.nocourses": "No starred courses", "addon.block_starredcourses.pluginname": "Starred courses", + "addon.block_tags.pluginname": "Tags", "addon.block_timeline.duedate": "Due date", "addon.block_timeline.next30days": "Next 30 days", "addon.block_timeline.next3months": "Next 3 months", @@ -66,18 +83,61 @@ "addon.blog.publishtoworld": "Anyone in the world", "addon.blog.showonlyyourentries": "Show only your entries", "addon.blog.siteblogheading": "Site blog", + "addon.calendar.allday": "All day", "addon.calendar.calendar": "Calendar", + "addon.calendar.calendarevent": "Calendar event", "addon.calendar.calendarevents": "Calendar events", "addon.calendar.calendarreminders": "Calendar reminders", + "addon.calendar.confirmeventdelete": "Are you sure you want to delete the \"{{$a}}\" event?", + "addon.calendar.confirmeventseriesdelete": "The \"{{$a.name}}\" event is part of a series. Do you want to delete just this event, or all {{$a.count}} events in the series?", + "addon.calendar.currentmonth": "Current Month", + "addon.calendar.daynext": "Next day", + "addon.calendar.dayprev": "Previous day", "addon.calendar.defaultnotificationtime": "Default notification time", + "addon.calendar.deleteallevents": "Delete all events", + "addon.calendar.deleteevent": "Delete event", + "addon.calendar.deleteoneevent": "Delete this event", + "addon.calendar.durationminutes": "Duration in minutes", + "addon.calendar.durationnone": "Without duration", + "addon.calendar.durationuntil": "Until", + "addon.calendar.editevent": "Editing event", "addon.calendar.errorloadevent": "Error loading event.", "addon.calendar.errorloadevents": "Error loading events.", + "addon.calendar.eventcalendareventdeleted": "Calendar event deleted", + "addon.calendar.eventduration": "Duration", "addon.calendar.eventendtime": "End time", + "addon.calendar.eventkind": "Type of event", + "addon.calendar.eventname": "Event title", "addon.calendar.eventstarttime": "Start time", + "addon.calendar.eventtype": "Event type", + "addon.calendar.fri": "Fri", + "addon.calendar.friday": "Friday", "addon.calendar.gotoactivity": "Go to activity", + "addon.calendar.invalidtimedurationminutes": "The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.", + "addon.calendar.invalidtimedurationuntil": "The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.", + "addon.calendar.mon": "Mon", + "addon.calendar.monday": "Monday", + "addon.calendar.monthlyview": "Monthly view", + "addon.calendar.newevent": "New event", "addon.calendar.noevents": "There are no events", + "addon.calendar.nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event", "addon.calendar.reminders": "Reminders", + "addon.calendar.repeatedevents": "Repeated events", + "addon.calendar.repeateditall": "Also apply changes to the other {{$a}} events in this repeat series", + "addon.calendar.repeateditthis": "Apply changes to this event only", + "addon.calendar.repeatevent": "Repeat this event", + "addon.calendar.repeatweeksl": "Repeat weekly, creating altogether", + "addon.calendar.sat": "Sat", + "addon.calendar.saturday": "Saturday", "addon.calendar.setnewreminder": "Set a new reminder", + "addon.calendar.sun": "Sun", + "addon.calendar.sunday": "Sunday", + "addon.calendar.thu": "Thu", + "addon.calendar.thursday": "Thursday", + "addon.calendar.today": "Today", + "addon.calendar.tomorrow": "Tomorrow", + "addon.calendar.tue": "Tue", + "addon.calendar.tuesday": "Tuesday", "addon.calendar.typecategory": "Category event", "addon.calendar.typeclose": "Close event", "addon.calendar.typecourse": "Course event", @@ -87,6 +147,11 @@ "addon.calendar.typeopen": "Open event", "addon.calendar.typesite": "Site event", "addon.calendar.typeuser": "User event", + "addon.calendar.upcomingevents": "Upcoming events", + "addon.calendar.wed": "Wed", + "addon.calendar.wednesday": "Wednesday", + "addon.calendar.when": "When", + "addon.calendar.yesterday": "Yesterday", "addon.competency.activities": "Activities", "addon.competency.competencies": "Competencies", "addon.competency.competenciesmostoftennotproficientincourse": "Competencies most often not proficient in this course", @@ -355,6 +420,7 @@ "addon.mod_assign_submission_onlinetext.wordlimitexceeded": "The word limit for this assignment is {{$a.limit}} words and you are attempting to submit {{$a.count}} words. Please review your submission and try again.", "addon.mod_book.errorchapter": "Error reading chapter of book.", "addon.mod_book.modulenameplural": "Books", + "addon.mod_book.tagarea_book_chapters": "Book chapters", "addon.mod_book.toc": "Table of contents", "addon.mod_chat.beep": "Beep", "addon.mod_chat.chatreport": "Chat sessions", @@ -414,6 +480,7 @@ "addon.mod_data.confirmdeleterecord": "Are you sure you want to delete this entry?", "addon.mod_data.descending": "Descending", "addon.mod_data.disapprove": "Undo approval", + "addon.mod_data.edittagsnotsupported": "Sorry, editing tags is not supported by the app.", "addon.mod_data.emptyaddform": "You did not fill out any fields!", "addon.mod_data.entrieslefttoadd": "You must add {{$a.entriesleft}} more entry/entries in order to complete this activity", "addon.mod_data.entrieslefttoaddtoview": "You must add {{$a.entrieslefttoview}} more entry/entries before you can view other participants' entries.", @@ -438,8 +505,10 @@ "addon.mod_data.recorddisapproved": "Entry unapproved", "addon.mod_data.resetsettings": "Reset filters", "addon.mod_data.search": "Search", + "addon.mod_data.searchbytagsnotsupported": "Sorry, searching by tags is not supported by the app.", "addon.mod_data.selectedrequired": "All selected required", "addon.mod_data.single": "View single", + "addon.mod_data.tagarea_data_records": "Data records", "addon.mod_data.timeadded": "Time added", "addon.mod_data.timemodified": "Time modified", "addon.mod_data.usedate": "Include in search.", @@ -532,6 +601,7 @@ "addon.mod_forum.reply": "Reply", "addon.mod_forum.replyplaceholder": "Write your reply...", "addon.mod_forum.subject": "Subject", + "addon.mod_forum.tagarea_forum_posts": "Forum posts", "addon.mod_forum.thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", "addon.mod_forum.thisforumisdue": "The due date for posting to this forum was {{$a}}.", "addon.mod_forum.unlockdiscussion": "Unlock this discussion", @@ -566,9 +636,11 @@ "addon.mod_glossary.modulenameplural": "Glossaries", "addon.mod_glossary.noentriesfound": "No entries were found.", "addon.mod_glossary.searchquery": "Search query", + "addon.mod_glossary.tagarea_glossary_entries": "Glossary entries", "addon.mod_imscp.deploymenterror": "Content package error!", "addon.mod_imscp.modulenameplural": "IMS content packages", "addon.mod_imscp.showmoduledescription": "Show description", + "addon.mod_imscp.toc": "TOC", "addon.mod_lesson.answer": "Answer", "addon.mod_lesson.attempt": "Attempt: {{$a}}", "addon.mod_lesson.attemptheader": "Attempt", @@ -665,6 +737,7 @@ "addon.mod_quiz.attemptnumber": "Attempt", "addon.mod_quiz.attemptquiznow": "Attempt quiz now", "addon.mod_quiz.attemptstate": "State", + "addon.mod_quiz.canattemptbutnotsubmit": "You can attempt this quiz in the app, but you will need to submit the attempt in browser for the following reasons:", "addon.mod_quiz.cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:", "addon.mod_quiz.clearchoice": "Clear my choice", "addon.mod_quiz.comment": "Comment", @@ -683,7 +756,7 @@ "addon.mod_quiz.errorgetquestions": "Error getting questions.", "addon.mod_quiz.errorgetquiz": "Error getting quiz data.", "addon.mod_quiz.errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.", - "addon.mod_quiz.errorquestionsnotsupported": "This quiz can't be attempted in the app because it contains questions not supported by the app:", + "addon.mod_quiz.errorquestionsnotsupported": "This quiz can't be attempted in the app because it only contains questions not supported by the app:", "addon.mod_quiz.errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:", "addon.mod_quiz.errorsaveattempt": "An error occurred while saving the attempt data.", "addon.mod_quiz.feedback": "Feedback", @@ -737,6 +810,7 @@ "addon.mod_quiz.warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.", "addon.mod_quiz.warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.", "addon.mod_quiz.warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.", + "addon.mod_quiz.warningquestionsnotsupported": "This quiz contains questions not supported by the app:", "addon.mod_quiz.yourfinalgradeis": "Your final grade for this quiz is {{$a}}.", "addon.mod_resource.errorwhileloadingthecontent": "Error while loading the content.", "addon.mod_resource.modifieddate": "Modified {{$a}}", @@ -820,6 +894,7 @@ "addon.mod_wiki.pageexists": "This page already exists.", "addon.mod_wiki.pagename": "Page name", "addon.mod_wiki.subwiki": "Sub-wiki", + "addon.mod_wiki.tagarea_wiki_pages": "Wiki pages", "addon.mod_wiki.titleshouldnotbeempty": "The title should not be empty", "addon.mod_wiki.viewpage": "View page", "addon.mod_wiki.wikipage": "Wiki page", @@ -898,7 +973,9 @@ "addon.mod_workshop_assessment_rubric.mustchooseone": "You have to select one of these items", "addon.notes.addnewnote": "Add a new note", "addon.notes.coursenotes": "Course notes", + "addon.notes.deleteconfirm": "Delete this note?", "addon.notes.eventnotecreated": "Note created", + "addon.notes.eventnotedeleted": "Note deleted", "addon.notes.nonotes": "There are no notes of this type yet", "addon.notes.note": "Note", "addon.notes.notes": "Notes", @@ -1231,6 +1308,7 @@ "core.answered": "Answered", "core.areyousure": "Are you sure?", "core.back": "Back", + "core.block.blocks": "Blocks", "core.cancel": "Cancel", "core.cannotconnect": "Cannot connect: Verify that you have correctly typed the URL and that your site uses Moodle 2.4 or later.", "core.cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", @@ -1245,9 +1323,16 @@ "core.clicktohideshow": "Click to expand or collapse", "core.clicktoseefull": "Click to see full contents.", "core.close": "Close", - "core.comments": "Comments", - "core.commentscount": "Comments ({{$a}})", - "core.commentsnotworking": "Comments cannot be retrieved", + "core.comments.addcomment": "Add a comment...", + "core.comments.comments": "Comments", + "core.comments.commentscount": "Comments ({{$a}})", + "core.comments.commentsnotworking": "Comments cannot be retrieved", + "core.comments.deletecommentbyon": "Delete comment posted by {{$a.user}} on {{$a.time}}", + "core.comments.eventcommentcreated": "Comment created", + "core.comments.eventcommentdeleted": "Comment deleted", + "core.comments.nocomments": "No comments", + "core.comments.savecomment": "Save comment", + "core.comments.warningcommentsnotsent": "Couldn't sync comments. {{error}}", "core.completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", "core.completion-alt-auto-n": "Not completed: {{$a}}", "core.completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", @@ -1260,6 +1345,8 @@ "core.completion-alt-manual-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as not complete.", "core.confirmcanceledit": "Are you sure you want to leave this page? All changes will be lost.", "core.confirmdeletefile": "Are you sure you want to delete this file?", + "core.confirmgotabroot": "Are you sure you want to go back to {{name}}?", + "core.confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", "core.confirmloss": "Are you sure? All changes will be lost.", "core.confirmopeninbrowser": "Do you want to open it in a web browser?", "core.considereddigitalminor": "You are too young to create an account on this site.", @@ -1306,6 +1393,7 @@ "core.course.warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.", "core.course.warningofflinemanualcompletiondeleted": "Some offline manual completion of course '{{name}}' has been deleted. {{error}}", "core.coursedetails": "Course details", + "core.coursenogroups": "You are not a member of any group of this course.", "core.courses.addtofavourites": "Star this course", "core.courses.allowguests": "This course allows guest users to enter", "core.courses.availablecourses": "Available courses", @@ -1317,12 +1405,13 @@ "core.courses.enrolme": "Enrol me", "core.courses.errorloadcategories": "An error occurred while loading categories.", "core.courses.errorloadcourses": "An error occurred while loading courses.", - "core.courses.errorloadplugins": "The plugins required by this course could not be loaded correctly. Please restart the app to try again.", + "core.courses.errorloadplugins": "The plugins required by this course could not be loaded correctly. Please reload the app to try again.", "core.courses.errorsearching": "An error occurred while searching.", "core.courses.errorselfenrol": "An error occurred while self enrolling.", "core.courses.filtermycourses": "Filter my courses", "core.courses.frontpage": "Front page", "core.courses.hidecourse": "Hide from view", + "core.courses.ignore": "Ignore", "core.courses.mycourses": "My courses", "core.courses.mymoodle": "Dashboard", "core.courses.nocourses": "No course information to show.", @@ -1333,6 +1422,7 @@ "core.courses.password": "Enrolment key", "core.courses.paymentrequired": "This course requires a payment for entry.", "core.courses.paypalaccepted": "PayPal payments accepted", + "core.courses.reload": "Reload", "core.courses.removefromfavourites": "Unstar this course", "core.courses.search": "Search", "core.courses.searchcourses": "Search courses", @@ -1383,6 +1473,7 @@ "core.erroropenfilenoextension": "Error opening file: the file doesn't have an extension.", "core.erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.", "core.errorrenamefile": "Error renaming file. Please try again.", + "core.errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.", "core.errorsync": "An error occurred while synchronising. Please try again.", "core.errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", "core.explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", @@ -1435,6 +1526,7 @@ "core.grades.range": "Range", "core.grades.rank": "Rank", "core.grades.weight": "Weight", + "core.group": "Group", "core.groupsseparate": "Separate groups", "core.groupsvisible": "Visible groups", "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", @@ -1446,6 +1538,7 @@ "core.image": "Image", "core.imageviewer": "Image viewer", "core.info": "Information", + "core.invalidformdata": "Incorrect form data", "core.ios": "iOS", "core.labelsep": ":", "core.lastaccess": "Last access", @@ -1465,6 +1558,8 @@ "core.login.confirmdeletesite": "Are you sure you want to delete the site {{sitename}}?", "core.login.connect": "Connect!", "core.login.connecttomoodle": "Connect to Moodle", + "core.login.connecttomoodleapp": "You are trying to connect to a regular Moodle site. Please download the official Moodle app to access this site.", + "core.login.connecttoworkplaceapp": "You are trying to connect to a Moodle Workplace site. Please download the Moodle Workplace app to access this site.", "core.login.contactyouradministrator": "Contact your site administrator for further help.", "core.login.contactyouradministratorissue": "Please ask your site administrator to check the following issue: {{$a}}", "core.login.createaccount": "Create my new account", @@ -1494,7 +1589,7 @@ "core.login.invalidurl": "Invalid URL specified", "core.login.invalidvaluemax": "The maximum value is {{$a}}", "core.login.invalidvaluemin": "The minimum value is {{$a}}", - "core.login.legacymoodleversion": "You are trying to connect to an unsupported Moodle version. Please, download the Moodle Classic app to access this Moodle site.", + "core.login.legacymoodleversion": "You are trying to connect to an unsupported Moodle version. Please download the Moodle Classic app to access this Moodle site.", "core.login.legacymoodleversiondesktop": "You are trying to connect to {{$a}}.

This site is running an outdated unsupported version of Moodle which will not work with this Moodle Desktop App.

If this is your site please contact your local moodle partner to get assistance to update it.

See
our contact page to submit a request for assistance.", "core.login.legacymoodleversiondesktopdownloadold": "

Alternatively, you can still access this site using an unsupported version of the app that can be downloaded from here.", "core.login.localmobileunexpectedresponse": "Moodle Mobile Additional Features check returned an unexpected response. You will be authenticated using the standard mobile service.", @@ -1595,19 +1690,20 @@ "core.never": "Never", "core.next": "Next", "core.no": "No", - "core.nocomments": "No comments", "core.nograde": "No grade", "core.none": "None", "core.nopasswordchangeforced": "You cannot proceed without changing your password.", "core.nopermissionerror": "Sorry, but you do not currently have permissions to do that", "core.nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", "core.noresults": "No results", + "core.noselection": "No selection", "core.notapplicable": "n/a", "core.notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", "core.notice": "Notice", "core.notingroup": "Sorry, but you need to be part of a group to see this page.", "core.notsent": "Not sent", "core.now": "now", + "core.nummore": "{{$a}} more", "core.numwords": "{{$a}} words", "core.offline": "Offline", "core.ok": "OK", @@ -1670,6 +1766,9 @@ "core.sec": "sec", "core.secs": "secs", "core.seemoredetail": "Click here to see more detail", + "core.selectacategory": "Please select a category", + "core.selectacourse": "Select a course", + "core.selectagroup": "Select a group", "core.send": "Send", "core.sending": "Sending", "core.serverconnection": "Error connecting to the server", @@ -1695,6 +1794,8 @@ "core.settings.disabled": "Disabled", "core.settings.displayformat": "Display format", "core.settings.enabledownloadsection": "Enable download sections", + "core.settings.enablefirebaseanalytics": "Enable Firebase analytics", + "core.settings.enablefirebaseanalyticsdescription": "If enabled, the app will collect anonymous data usage.", "core.settings.enablerichtexteditor": "Enable text editor", "core.settings.enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.", "core.settings.enablesyncwifi": "Allow sync only when on Wi-Fi", @@ -1703,6 +1804,8 @@ "core.settings.errorsyncsite": "Error synchronising site data. Please check your Internet connection and try again.", "core.settings.estimatedfreespace": "Estimated free space", "core.settings.filesystemroot": "File system root", + "core.settings.fontsize": "Text size", + "core.settings.fontsizecharacter": "A", "core.settings.general": "General", "core.settings.language": "Language", "core.settings.license": "Licence", @@ -1738,6 +1841,7 @@ "core.sharedfiles.sharedfiles": "Shared files", "core.sharedfiles.successstorefile": "File successfully stored. Select the file to upload to your private files or use in an activity.", "core.show": "Show", + "core.showless": "Show less...", "core.showmore": "Show more...", "core.site": "Site", "core.sitehome.sitehome": "Site home", @@ -1770,6 +1874,20 @@ "core.submit": "Submit", "core.success": "Success", "core.tablet": "Tablet", + "core.tag.defautltagcoll": "Default collection", + "core.tag.errorareanotsupported": "This tag area is not supported by the app.", + "core.tag.inalltagcoll": "Everywhere", + "core.tag.itemstaggedwith": "{{$a.tagarea}} tagged with \"{{$a.tag}}\"", + "core.tag.notagsfound": "No tags matching \"{{$a}}\" found", + "core.tag.searchtags": "Search tags", + "core.tag.showingfirsttags": "Showing {{$a}} most popular tags", + "core.tag.tag": "Tag", + "core.tag.tagarea_course": "Courses", + "core.tag.tagarea_course_modules": "Activities and resources", + "core.tag.tagarea_post": "Blog posts", + "core.tag.tagarea_user": "User interests", + "core.tag.tags": "Tags", + "core.tag.warningareasnotsupported": "Some of the tag areas are not displayed because they are not supported by the app.", "core.teachers": "Teachers", "core.thereisdatatosync": "There are offline {{$a}} to be synchronised.", "core.thisdirection": "ltr", @@ -1786,6 +1904,7 @@ "core.unlimited": "Unlimited", "core.unzipping": "Unzipping", "core.upgraderunning": "Site is being upgraded, please retry later.", + "core.user": "User", "core.user.address": "Address", "core.user.city": "City/town", "core.user.contact": "Contact", diff --git a/src/classes/site.ts b/src/classes/site.ts index 9253f83e631..4f885fe9312 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -21,7 +21,7 @@ import { CoreDbProvider } from '@providers/db'; import { CoreEventsProvider } from '@providers/events'; import { CoreFileProvider } from '@providers/file'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreWSProvider, CoreWSPreSets, CoreWSFileUploadOptions } from '@providers/ws'; +import { CoreWSProvider, CoreWSPreSets, CoreWSFileUploadOptions, CoreWSAjaxPreSets } from '@providers/ws'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; @@ -60,6 +60,12 @@ export interface CoreSiteWSPreSets { */ emergencyCache?: boolean; + /** + * If true, the app won't call the WS. If the data isn't cached, the call will fail. + * @type {boolean} + */ + forceOffline?: boolean; + /** * Extra key to add to the cache when storing this call, to identify the entry. * @type {string} @@ -668,7 +674,12 @@ export class CoreSite { } const promise = this.getFromCache(method, data, preSets, false, originalData).catch(() => { - // Do not pass those options to the core WS factory. + if (preSets.forceOffline) { + // Don't call the WS, just fail. + return Promise.reject(this.wsProvider.createFakeWSError('core.cannotconnect', true)); + } + + // Call the WS. return this.callOrEnqueueRequest(method, data, preSets, wsPreSets).then((response) => { if (preSets.saveToCache) { this.saveToCache(method, data, response, preSets); @@ -739,15 +750,15 @@ export class CoreSite { // Save the error instead of deleting the cache entry so the same content is displayed in offline. this.saveToCache(method, data, error, preSets); - return Promise.reject(error); - } else if (typeof preSets.emergencyCache !== 'undefined' && !preSets.emergencyCache) { - this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); - return Promise.reject(error); } else if (preSets.cacheErrors && preSets.cacheErrors.indexOf(error.errorcode) != -1) { // Save the error instead of deleting the cache entry so the same content is displayed in offline. this.saveToCache(method, data, error, preSets); + return Promise.reject(error); + } else if (typeof preSets.emergencyCache !== 'undefined' && !preSets.emergencyCache) { + this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); + return Promise.reject(error); } @@ -1043,7 +1054,7 @@ export class CoreSite { const now = Date.now(); let expirationTime; - preSets.omitExpires = preSets.omitExpires || !this.appProvider.isOnline(); + preSets.omitExpires = preSets.omitExpires || preSets.forceOffline || !this.appProvider.isOnline(); if (!preSets.omitExpires) { let expirationDelay = this.UPDATE_FREQUENCIES[preSets.updateFrequency] || @@ -1323,7 +1334,7 @@ export class CoreSite { return Promise.resolve({ code: 0 }); } - const promise = this.http.post(checkUrl, { service: service }).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + const promise = this.http.post(checkUrl, { service: service }).timeout(this.wsProvider.getRequestTimeout()).toPromise(); return promise.then((data: any) => { if (typeof data != 'undefined' && data.errorcode === 'requirecorrectaccess') { @@ -1432,7 +1443,30 @@ export class CoreSite { * @return {Promise} Promise resolved with public config. Rejected with an object if error, see CoreWSProvider.callAjax. */ getPublicConfig(): Promise { - return this.wsProvider.callAjax('tool_mobile_get_public_config', {}, { siteUrl: this.siteUrl }).then((config) => { + const preSets: CoreWSAjaxPreSets = { + siteUrl: this.siteUrl + }; + + return this.wsProvider.callAjax('tool_mobile_get_public_config', {}, preSets).catch((error) => { + + if ((!this.getInfo() || this.isVersionGreaterEqualThan('3.8')) && error && error.errorcode == 'codingerror') { + // This error probably means that there is a redirect in the site. Try to use a GET request. + preSets.noLogin = true; + preSets.useGet = true; + + return this.wsProvider.callAjax('tool_mobile_get_public_config', {}, preSets).catch((error2) => { + if (this.getInfo() && this.isVersionGreaterEqualThan('3.8')) { + // GET is supported, return the second error. + return Promise.reject(error2); + } else { + // GET not supported or we don't know if it's supported. Return first error. + return Promise.reject(error); + } + }); + } + + return Promise.reject(error); + }).then((config) => { // Use the wwwroot returned by the server. if (config.httpswwwroot) { this.siteUrl = config.httpswwwroot; diff --git a/src/components/context-menu/context-menu.scss b/src/components/context-menu/context-menu.scss index 138e4b4c727..43c1468e5f7 100644 --- a/src/components/context-menu/context-menu.scss +++ b/src/components/context-menu/context-menu.scss @@ -1,4 +1,7 @@ ion-app.app-root core-context-menu-popover { + .list { + margin-bottom: 0; + } .item-md ion-icon[item-start] + .item-inner, .item-md ion-icon[item-start] + .item-input { @include margin-horizontal(5px, null); diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts index ff6413b6773..42fca4504a9 100644 --- a/src/components/context-menu/context-menu.ts +++ b/src/components/context-menu/context-menu.ts @@ -178,7 +178,7 @@ export class CoreContextMenuComponent implements OnInit, OnDestroy { showContextMenu(event: MouseEvent): void { if (!this.expanded) { const popover = this.popoverCtrl.create(CoreContextMenuPopoverComponent, - { title: this.title, items: this.items, id: this.uniqueId }); + { title: this.title, items: this.items, id: this.uniqueId, showBackdrop: true }); popover.onDidDismiss(() => { this.expanded = false; diff --git a/src/components/empty-box/empty-box.scss b/src/components/empty-box/empty-box.scss index 5d265575c55..e5d0c3edb0d 100644 --- a/src/components/empty-box/empty-box.scss +++ b/src/components/empty-box/empty-box.scss @@ -61,3 +61,8 @@ ion-app.app-root core-empty-box { } } } + +ion-app.app-root core-block-course-blocks core-empty-box .core-empty-box { + position: relative; +} + diff --git a/src/components/iframe/iframe.ts b/src/components/iframe/iframe.ts index 5988289a735..722d9eae633 100644 --- a/src/components/iframe/iframe.ts +++ b/src/components/iframe/iframe.ts @@ -12,11 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, OnInit, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; +import { + Component, Input, Output, OnInit, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange, Optional +} from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { NavController } from 'ionic-angular'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreIframeUtilsProvider } from '@providers/utils/iframe'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** */ @@ -38,7 +42,9 @@ export class CoreIframeComponent implements OnInit, OnChanges { protected IFRAME_TIMEOUT = 15000; constructor(logger: CoreLoggerProvider, private iframeUtils: CoreIframeUtilsProvider, private domUtils: CoreDomUtilsProvider, - private sanitizer: DomSanitizer) { + private sanitizer: DomSanitizer, private navCtrl: NavController, + @Optional() private svComponent: CoreSplitViewComponent) { + this.logger = logger.getInstance('CoreIframe'); this.loaded = new EventEmitter(); } @@ -55,7 +61,8 @@ export class CoreIframeComponent implements OnInit, OnChanges { // Show loading only with external URLs. this.loading = !this.src || !!this.src.match(/^https?:\/\//i); - this.iframeUtils.treatFrame(iframe); + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + this.iframeUtils.treatFrame(iframe, false, navCtrl); if (this.loading) { iframe.addEventListener('load', () => { diff --git a/src/components/ion-tabs/core-ion-tabs.html b/src/components/ion-tabs/core-ion-tabs.html index 123d340c1c4..5a4dda4b37b 100644 --- a/src/components/ion-tabs/core-ion-tabs.html +++ b/src/components/ion-tabs/core-ion-tabs.html @@ -1,5 +1,5 @@
- +
diff --git a/src/components/ion-tabs/ion-tabs.scss b/src/components/ion-tabs/ion-tabs.scss index fabba182802..31f66765086 100644 --- a/src/components/ion-tabs/ion-tabs.scss +++ b/src/components/ion-tabs/ion-tabs.scss @@ -23,6 +23,12 @@ ion-app.app-root core-ion-tabs { background-color: $ion-tabs-badge-color; } + &[tabsplacement="bottom"] { + .ion-page > ion-content > .scroll-content { + margin-bottom: $navbar-md-height; + } + } + &[tabsplacement="side"] { .tabbar { @include float(start); @@ -53,6 +59,10 @@ ion-app.app-root core-ion-tabs { position: relative; } } + + .scroll-content, .fixed-content { + margin-bottom: 0 !important; + } } } @@ -79,9 +89,6 @@ core-ion-tabs, core-ion-tab { width: 100%; height: 100%; overflow: hidden; - - // Do not use "contain: strict" so fullscreen iframes work. - contain: size layout style; } core-ion-tab { diff --git a/src/components/ion-tabs/ion-tabs.ts b/src/components/ion-tabs/ion-tabs.ts index deeb76a531b..914d97ab6aa 100644 --- a/src/components/ion-tabs/ion-tabs.ts +++ b/src/components/ion-tabs/ion-tabs.ts @@ -20,11 +20,14 @@ import { import { CoreIonTabComponent } from './ion-tab'; import { CoreUtilsProvider, PromiseDefer } from '@providers/utils/utils'; import { CoreAppProvider } from '@providers/app'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { TranslateService } from '@ngx-translate/core'; /** - * Equivalent to ion-tabs. It has 2 improvements: + * Equivalent to ion-tabs. It has several improvements: * - If a core-ion-tab is added or removed, it will be reflected in the tab bar in the right position. * - It supports a loaded input to tell when are the tabs ready. + * - When the user clicks the tab again to go to root, a confirm modal is shown. */ @Component({ selector: 'core-ion-tabs', @@ -73,7 +76,8 @@ export class CoreIonTabsComponent extends Tabs implements OnDestroy { constructor(protected utils: CoreUtilsProvider, protected appProvider: CoreAppProvider, @Optional() parent: NavController, @Optional() viewCtrl: ViewController, _app: App, config: Config, elementRef: ElementRef, _plt: Platform, - renderer: Renderer, _linker: DeepLinker, keyboard?: Keyboard) { + renderer: Renderer, _linker: DeepLinker, protected domUtils: CoreDomUtilsProvider, + protected translate: TranslateService, keyboard?: Keyboard) { super(parent, viewCtrl, _app, config, elementRef, _plt, renderer, _linker, keyboard); } @@ -272,13 +276,25 @@ export class CoreIonTabsComponent extends Tabs implements OnDestroy { * * @param {number|Tab} tabOrIndex Index, or the Tab instance, of the tab to select. * @param {NavOptions} Nav options. - * @param {boolean} [fromUrl=true] Whether to load from a URL. + * @param {boolean} [fromUrl] Whether to load from a URL. + * @param {boolean} [manualClick] Whether the user manually clicked the tab. * @return {Promise} Promise resolved when selected. */ - select(tabOrIndex: number | Tab, opts: NavOptions = {}, fromUrl: boolean = false): Promise { + select(tabOrIndex: number | Tab, opts: NavOptions = {}, fromUrl?: boolean, manualClick?: boolean): Promise { if (this.initialized) { // Tabs have been initialized, select the tab. + if (manualClick) { + // If we'll go to the root of the current tab, ask the user to confirm first. + const tab = typeof tabOrIndex == 'number' ? this.getByIndex(tabOrIndex) : tabOrIndex; + + return this.confirmGoToRoot(tab).then(() => { + return super.select(tabOrIndex, opts, fromUrl); + }, () => { + // User cancelled. + }); + } + return super.select(tabOrIndex, opts, fromUrl); } else { // Tabs not initialized yet. Mark it as "selectedIndex" input so it's treated when the tabs are initialized. @@ -305,11 +321,16 @@ export class CoreIonTabsComponent extends Tabs implements OnDestroy { if (this.initialized) { const tab = this.getByIndex(index); if (tab) { - return tab.goToRoot({animate: false, updateUrl: true, isNavRoot: true}).then(() => { - // Tab not previously selected. Select it after going to root. - if (!tab.isSelected) { - return this.select(tab, {animate: false, updateUrl: true, isNavRoot: true}); - } + return this.confirmGoToRoot(tab).then(() => { + // User confirmed, go to root. + return tab.goToRoot({animate: tab.isSelected, updateUrl: true, isNavRoot: true}).then(() => { + // Tab not previously selected. Select it after going to root. + if (!tab.isSelected) { + return this.select(tab, {animate: false, updateUrl: true, isNavRoot: true}); + } + }); + }, () => { + // User cancelled. }); } @@ -349,4 +370,23 @@ export class CoreIonTabsComponent extends Tabs implements OnDestroy { // Unregister the custom back button action for this page this.unregisterBackButtonAction && this.unregisterBackButtonAction(); } + + /** + * Confirm if the user wants to go to the root of the current tab. + * + * @param {Tab} tab Tab to go to root. + * @return {Promise} Promise resolved when confirmed. + */ + confirmGoToRoot(tab: Tab): Promise { + if (!tab || !tab.isSelected || (tab.getActive() && tab.getActive().isFirst())) { + // Tab not selected or is already at root, no need to confirm. + return Promise.resolve(); + } else { + if (tab.tabTitle) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmgotabroot', {name: tab.tabTitle})); + } else { + return this.domUtils.showConfirm(this.translate.instant('core.confirmgotabrootdefault')); + } + } + } } diff --git a/src/components/loading/loading.scss b/src/components/loading/loading.scss index 92f00180552..bc1faec9fe3 100644 --- a/src/components/loading/loading.scss +++ b/src/components/loading/loading.scss @@ -21,12 +21,14 @@ ion-app.app-root { .scroll-content > core-loading, ion-content > .scroll-content > core-loading, + core-tab core-loading, .core-loading-center { position: static !important; } .scroll-content > core-loading, ion-content > .scroll-content > core-loading, + core-tab core-loading, .core-loading-center, core-loading.core-loading-loaded { position: relative; diff --git a/src/components/navbar-buttons/navbar-buttons.ts b/src/components/navbar-buttons/navbar-buttons.ts index 7f4ae239be7..c38931322a7 100644 --- a/src/components/navbar-buttons/navbar-buttons.ts +++ b/src/components/navbar-buttons/navbar-buttons.ts @@ -25,6 +25,8 @@ import { CoreContextMenuComponent } from '../context-menu/context-menu'; * If this component indicates a position (start/end), the buttons will only be added if the header has some buttons in that * position. If no start/end is specified, then the buttons will be added to the first found in the header. * + * If this component has a "prepend" attribute, the buttons will be added before other existing buttons in the header. + * * You can use the [hidden] input to hide all the inner buttons if a certain condition is met. * * IMPORTANT: Do not use *ngIf in the buttons inside this component, it can cause problems. Please use [hidden] instead. @@ -92,7 +94,8 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { if (buttonsContainer) { this.mergeContextMenus(buttonsContainer); - this.movedChildren = this.domUtils.moveChildren(this.element, buttonsContainer); + const prepend = this.element.hasAttribute('prepend'); + this.movedChildren = this.domUtils.moveChildren(this.element, buttonsContainer, prepend); this.showHideAllElements(); } else { diff --git a/src/components/rich-text-editor/core-rich-text-editor.html b/src/components/rich-text-editor/core-rich-text-editor.html index 7a02610ddf7..ba2bd897f53 100644 --- a/src/components/rich-text-editor/core-rich-text-editor.html +++ b/src/components/rich-text-editor/core-rich-text-editor.html @@ -1,33 +1,81 @@ -
-
-
- - -
-
- - - - - - - - - - - - -
-
-
- -
- -
-
- -
-
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/components/rich-text-editor/rich-text-editor.scss b/src/components/rich-text-editor/rich-text-editor.scss index 8bce984e8e2..9460297fd17 100644 --- a/src/components/rich-text-editor/rich-text-editor.scss +++ b/src/components/rich-text-editor/rich-text-editor.scss @@ -4,27 +4,20 @@ ion-app.app-root core-rich-text-editor { min-height: 200px; /* Just in case vh is not supported */ min-height: 40vh; width: 100%; - position: relative; - display: block; + display: flex; + flex-direction: column; - > div { - position: absolute; - @include position(0, 0, 0, 0); - height: 100%; - width: 100%; - display: flex; - flex-direction: column; - } .core-rte-editor, .core-textarea { padding: 2px; margin: 2px; width: 100%; resize: none; background-color: $white; - flex-grow: 1; } .core-rte-editor { + flex-grow: 1; + flex-shrink: 1; -webkit-user-select: auto !important; word-wrap: break-word; overflow-x: hidden; @@ -48,6 +41,8 @@ ion-app.app-root core-rich-text-editor { } .core-textarea { + flex-grow: 1; + flex-shrink: 1; position: relative; textarea { @@ -64,34 +59,67 @@ ion-app.app-root core-rich-text-editor { } div.core-rte-toolbar { - background: $gray-darker; - @include margin(0px, 1px, 15px, 1px); - text-align: center; - flex-grow: 0; + display: flex; width: 100%; z-index: 1; + flex-grow: 0; + flex-shrink: 0; + background-color: $white; + @include padding(5px, null); + border-top: 1px solid $gray; - .core-rte-buttons { + ion-slides { + width: 240px; + flex-grow: 1; + flex-shrink: 1; + } + + button { display: flex; + justify-content: center; align-items: center; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-evenly; - - button { - background: $gray-darker; - color: $white; - font-size: 1.1em; - height: 35px; - min-width: 30px; - @include padding(null, 3px, null, 3px); - @include border-end(qpx, solid, $gray-dark); - border-bottom: 1px solid $gray-dark; - @include position(-6px, 0, null, null); - flex-grow: 1; - margin: 0; + width: 36px; + height: 36px; + padding-right: 6px; + padding-left: 6px; + margin: 0 auto; + font-size: 18px; + background-color: $white; + border-radius: 4px; + @include core-transition(background-color, 200ms); + color: $text-color; + cursor: pointer; + + &.toolbar-button-enable { + width: 100%; + } + + &:active, &[aria-pressed="true"] { + background-color: $gray; + } + + &.toolbar-arrow { + width: 28px; + flex-grow: 0; + flex-shrink: 0; + opacity: 1; + @include core-transition(opacity, 200ms); + + &:active { + background-color: $white; + } + + &.toolbar-arrow-hidden { + opacity: 0; + } } } + + &.toolbar-hidden { + visibility: none; + height: 0; + border: none; + } } } diff --git a/src/components/rich-text-editor/rich-text-editor.ts b/src/components/rich-text-editor/rich-text-editor.ts index 59fdabe77f5..e9c8bc2b535 100644 --- a/src/components/rich-text-editor/rich-text-editor.ts +++ b/src/components/rich-text-editor/rich-text-editor.ts @@ -14,7 +14,7 @@ import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterContentInit, OnDestroy, Optional } from '@angular/core'; -import { TextInput, Content, Platform } from 'ionic-angular'; +import { TextInput, Content, Platform, Slides } from 'ionic-angular'; import { CoreSitesProvider } from '@providers/sites'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; @@ -56,11 +56,9 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy @ViewChild('editor') editor: ElementRef; // WYSIWYG editor. @ViewChild('textarea') textarea: TextInput; // Textarea editor. - @ViewChild('decorate') decorate: ElementRef; // Buttons. protected element: HTMLDivElement; protected editorElement: HTMLDivElement; - protected resizeFunction; protected kbHeight = 0; // Last known keyboard height. protected minHeight = 200; // Minimum height of the editor. @@ -71,6 +69,31 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy rteEnabled = false; editorSupported = true; + // Toolbar. + @ViewChild('toolbar') toolbar: ElementRef; + @ViewChild(Slides) toolbarSlides: Slides; + isPhone = this.platform.is('mobile') && !this.platform.is('tablet'); + toolbarHidden = this.isPhone; + numToolbarButtons = 6; + toolbarArrows = false; + toolbarPrevHidden = true; + toolbarNextHidden = false; + toolbarStyles = { + b: 'false', + i: 'false', + u: 'false', + strike: 'false', + p: 'false', + h1: 'false', + h2: 'false', + h3: 'false', + ul: 'false', + ol: 'false', + }; + protected isCurrentView = true; + protected toolbarButtonWidth = 40; + protected toolbarArrowWidth = 28; + constructor(private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private sitesProvider: CoreSitesProvider, private filepoolProvider: CoreFilepoolProvider, @Optional() private content: Content, elementRef: ElementRef, private events: CoreEventsProvider, @@ -80,7 +103,7 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy } /** - * Init editor + * Init editor. */ ngAfterContentInit(): void { this.domUtils.isRichTextEditorEnabled().then((enabled) => { @@ -106,8 +129,8 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy // Use paragraph on enter. document.execCommand('DefaultParagraphSeparator', false, 'p'); - this.resizeFunction = this.maximizeEditorSize.bind(this); - window.addEventListener('resize', this.resizeFunction); + window.addEventListener('resize', this.maximizeEditorSize); + document.addEventListener('selectionchange', this.updateToolbarStyles); let i = 0; this.initHeightInterval = setInterval(() => { @@ -123,6 +146,8 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy this.kbHeight = kbHeight; this.maximizeEditorSize(); }); + + this.updateToolbarButtons(); } /** @@ -130,13 +155,17 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy * * @return {Promise} Resolved with calculated editor size. */ - protected maximizeEditorSize(): Promise { + protected maximizeEditorSize = (): Promise => { this.content.resize(); const deferred = this.utils.promiseDefer(); setTimeout(() => { - const contentVisibleHeight = this.domUtils.getContentHeight(this.content) - this.kbHeight; + let contentVisibleHeight = this.domUtils.getContentHeight(this.content); + if (!this.platform.is('android')) { + // In Android we ignore the keyboard height because it is not part of the web view. + contentVisibleHeight -= this.kbHeight; + } if (contentVisibleHeight <= 0) { deferred.resolve(0); @@ -149,7 +178,7 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy let height; if (this.platform.is('android')) { - // Android, ignore keyboard height because web view is resized. + // In Android we ignore the keyboard height because it is not part of the web view. height = this.domUtils.getContentHeight(this.content) - this.getSurroundingHeight(this.element); } else if (this.platform.is('ios') && this.kbHeight > 0) { // Keyboard open in iOS. @@ -386,13 +415,16 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy this.rteEnabled = !this.rteEnabled; // Set focus and cursor at the end. - setTimeout(() => { - if (this.rteEnabled) { - this.editorElement.focus(); - } else { - this.textarea.setFocus(); - } - }); + // Modify the DOM directly so the keyboard stays open. + if (this.rteEnabled) { + this.editorElement.removeAttribute('hidden'); + this.textarea.getNativeElement().setAttribute('hidden', ''); + this.editorElement.focus(); + } else { + this.editorElement.setAttribute('hidden', ''); + this.textarea.getNativeElement().removeAttribute('hidden'); + this.textarea.setFocus(); + } } /** @@ -501,9 +533,8 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy * @param {any} $event Event data * @param {string} command Command to execute. */ - protected buttonAction($event: any, command: string): void { - $event.preventDefault(); - $event.stopPropagation(); + buttonAction($event: any, command: string): void { + this.stopBubble($event); if (command) { if (command.includes('|')) { @@ -517,12 +548,151 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy } } + /** + * Hide the toolbar. + */ + hideToolbar($event: any): void { + this.stopBubble($event); + + this.toolbarHidden = true; + } + + /** + * Show the toolbar. + */ + showToolbar(): void { + this.editorElement.focus(); + this.toolbarHidden = false; + } + + /** + * Stop event default and propagation. + * + * @param {Event} event Event. + */ + stopBubble(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Method that shows the next toolbar buttons. + */ + toolbarNext($event: any): void { + this.stopBubble($event); + + if (!this.toolbarNextHidden) { + const currentIndex = this.toolbarSlides.getActiveIndex() || 0; + this.toolbarSlides.slideTo(currentIndex + this.numToolbarButtons); + } + this.updateToolbarArrows(); + } + + /** + * Method that shows the previous toolbar buttons. + */ + toolbarPrev($event: any): void { + this.stopBubble($event); + + if (!this.toolbarPrevHidden) { + const currentIndex = this.toolbarSlides.getActiveIndex() || 0; + this.toolbarSlides.slideTo(currentIndex - this.numToolbarButtons); + } + this.updateToolbarArrows(); + } + + /** + * Update the number of toolbar buttons displayed. + */ + updateToolbarButtons(): void { + if (!this.isCurrentView) { + // Don't calculate if component isn't in current view, the calculations are wrong. + return; + } + + const width = this.domUtils.getElementWidth(this.toolbar.nativeElement); + + if (!(this.toolbarSlides as any)._init || !width) { + // Slides is not initialized or width is not available yet, try later. + setTimeout(this.updateToolbarButtons.bind(this), 100); + + return; + } + + if (width > this.toolbarSlides.length() * this.toolbarButtonWidth) { + this.numToolbarButtons = this.toolbarSlides.length(); + this.toolbarArrows = false; + } else { + this.numToolbarButtons = Math.floor((width - this.toolbarArrowWidth * 2) / this.toolbarButtonWidth); + this.toolbarArrows = true; + } + + this.toolbarSlides.update(); + + this.updateToolbarArrows(); + } + + /** + * Show or hide next/previous toolbar arrows. + */ + updateToolbarArrows(): void { + const currentIndex = this.toolbarSlides.getActiveIndex() || 0; + this.toolbarPrevHidden = currentIndex <= 0; + this.toolbarNextHidden = currentIndex + this.numToolbarButtons >= this.toolbarSlides.length(); + } + + /** + * Update highlighted toolbar styles. + */ + updateToolbarStyles = (): void => { + const node = document.getSelection().focusNode; + if (!node) { + return; + } + + let element = node.nodeType == 1 ? node as HTMLElement : node.parentElement; + const styles = {}; + + while (element != null && element !== this.editorElement) { + const tagName = element.tagName.toLowerCase(); + if (this.toolbarStyles[tagName]) { + styles[tagName] = 'true'; + } + element = element.parentElement; + } + + for (const tagName in this.toolbarStyles) { + this.toolbarStyles[tagName] = 'false'; + } + + if (element === this.editorElement) { + Object.assign(this.toolbarStyles, styles); + } + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + this.updateToolbarButtons(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + /** * Component being destroyed. */ ngOnDestroy(): void { this.valueChangeSubscription && this.valueChangeSubscription.unsubscribe(); - window.removeEventListener('resize', this.resizeFunction); + window.removeEventListener('resize', this.maximizeEditorSize); + document.removeEventListener('selectionchange', this.updateToolbarStyles); clearInterval(this.initHeightInterval); this.keyboardObs && this.keyboardObs.off(); } diff --git a/src/components/search-box/search-box.ts b/src/components/search-box/search-box.ts index 61922cbb353..e978908d163 100644 --- a/src/components/search-box/search-box.ts +++ b/src/components/search-box/search-box.ts @@ -39,6 +39,7 @@ export class CoreSearchBoxComponent implements OnInit { @Input() lengthCheck = 3; // Check value length before submit. If 0, any string will be submitted. @Input() showClear = true; // Show/hide clear button. @Input() disabled = false; // Disables the input text. + @Input() initialSearch: string; // Initial search text. @Output() onSubmit: EventEmitter; // Send data when submitting the search form. @Output() onClear: EventEmitter; // Send event when clearing the search form. @@ -55,6 +56,7 @@ export class CoreSearchBoxComponent implements OnInit { this.placeholder = this.placeholder || this.translate.instant('core.search'); this.spellcheck = this.utils.isTrueOrOne(this.spellcheck); this.showClear = this.utils.isTrueOrOne(this.showClear); + this.searchText = this.initialSearch || ''; } /** diff --git a/src/components/split-view/split-view.ts b/src/components/split-view/split-view.ts index 3e26f6e8672..ca807f10dac 100644 --- a/src/components/split-view/split-view.ts +++ b/src/components/split-view/split-view.ts @@ -60,6 +60,7 @@ export class CoreSplitViewComponent implements OnInit, OnDestroy { protected ignoreSplitChanged = false; protected audioCaptureSubscription: Subscription; protected languageChangedSubscription: Subscription; + protected pushOngoing: boolean; // Empty placeholder for the 'detail' page. detailPage: any = null; @@ -185,20 +186,29 @@ export class CoreSplitViewComponent implements OnInit, OnDestroy { * @param {boolean} [retrying] Whether it's retrying. */ push(page: any, params?: any, retrying?: boolean): void { - if (typeof this.isEnabled == 'undefined' && !retrying) { - // Hasn't calculated if it's enabled yet. Wait a bit and try again. - setTimeout(() => { - this.push(page, params, true); - }, 200); - } else { - if (this.isEnabled) { - this.detailNav.setRoot(page, params); + // Check there's no ongoing push. + if (!this.pushOngoing) { + if (typeof this.isEnabled == 'undefined' && !retrying) { + // Hasn't calculated if it's enabled yet. Wait a bit and try again. + setTimeout(() => { + this.push(page, params, true); + }, 200); } else { - this.loadDetailPage = { - component: page, - data: params - }; - this.masterNav.push(page, params); + this.pushOngoing = true; + let promise; + + if (this.isEnabled) { + promise = this.detailNav.setRoot(page, params); + } else { + this.loadDetailPage = { + component: page, + data: params + }; + promise = this.masterNav.push(page, params); + } + promise.finally(() => { + this.pushOngoing = false; + }); } } } diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts index 48b8a3dcb1b..fc4aaf2fc74 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -121,18 +121,16 @@ export class CoreTabComponent implements OnInit, OnDestroy { this.showHideNavBarButtons(true); // Setup tab scrolling. - setTimeout(() => { - // Workaround to solve undefined this.scroll on tab change. - const scroll: HTMLElement = this.content ? this.content.getScrollElement() : - this.element.querySelector('ion-content > .scroll-content'); - - if (scroll) { - scroll.onscroll = (e): void => { - this.tabs.showHideTabs(e.target); - }; - this.tabs.showHideTabs(scroll); - } - }, 1); + this.domUtils.waitElementToExist(() => this.content ? this.content.getScrollElement() : + this.element.querySelector('ion-content > .scroll-content')).then((scroll) => { + scroll.addEventListener('scroll', (e): void => { + this.tabs.showHideTabs(e.target); + }); + + this.tabs.showHideTabs(scroll); + }).catch(() => { + // Ignore errors. + }); } /** diff --git a/src/components/tabs/tabs.scss b/src/components/tabs/tabs.scss index 5dfa3c960ee..01568394df4 100644 --- a/src/components/tabs/tabs.scss +++ b/src/components/tabs/tabs.scss @@ -74,7 +74,7 @@ ion-app.app-root.ios .core-tabs-bar .tab-slide { max-width: $tabs-ios-tab-max-width; min-height: $tabs-ios-tab-min-height; - font-size: $tabs-ios-tab-font-size + 4; + font-size: $tabs-ios-tab-font-size; font-weight: $tabs-ios-tab-font-weight; color: $tabs-ios-tab-text-color; } @@ -102,7 +102,6 @@ ion-app.app-root core-tabs { .scroll-content { overflow: hidden !important; - contain: initial; position: relative; } } diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts index 5d457bfd886..ba7b9fdb15f 100644 --- a/src/components/tabs/tabs.ts +++ b/src/components/tabs/tabs.ts @@ -234,7 +234,15 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe */ calculateTabBarHeight(): void { this.tabBarHeight = this.topTabsElement.offsetHeight; - this.originalTabsContainer.style.paddingBottom = this.tabBarHeight + 'px'; + + if (this.tabsShown) { + // Smooth translation. + this.topTabsElement.style.transform = 'translateY(-' + this.lastScroll + 'px)'; + this.originalTabsContainer.style.transform = 'translateY(-' + this.lastScroll + 'px)'; + this.originalTabsContainer.style.paddingBottom = this.tabBarHeight - this.lastScroll + 'px'; + } else { + this.tabBarElement.classList.add('tabs-hidden'); + } } /** @@ -417,6 +425,13 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe const scroll = parseInt(scrollElement.scrollTop, 10); if (scroll == this.lastScroll) { + if (scroll == 0) { + // Ensure tabbar is shown. + this.topTabsElement.style.transform = ''; + this.originalTabsContainer.style.transform = ''; + this.originalTabsContainer.style.paddingBottom = this.tabBarHeight + 'px'; + } + // Ensure scroll has been modified to avoid flicks. return; } @@ -438,7 +453,8 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe this.originalTabsContainer.style.transform = 'translateY(-' + scroll + 'px)'; this.originalTabsContainer.style.paddingBottom = this.tabBarHeight - scroll + 'px'; } - this.lastScroll = scroll; + // Use lastScroll after moving the tabs to avoid flickering. + this.lastScroll = parseInt(scrollElement.scrollTop, 10); } /** @@ -472,7 +488,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe const currentTab = this.getSelected(), newTab = this.tabs[index]; - if (!newTab.enabled || !newTab.show) { + if (!newTab || !newTab.enabled || !newTab.show) { // The tab isn't enabled or shown, stop. return; } diff --git a/src/components/user-avatar/user-avatar.ts b/src/components/user-avatar/user-avatar.ts index 298ace24afb..22c6fe5a286 100644 --- a/src/components/user-avatar/user-avatar.ts +++ b/src/components/user-avatar/user-avatar.ts @@ -12,13 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange } from '@angular/core'; +import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Optional } from '@angular/core'; import { NavController } from 'ionic-angular'; import { CoreSitesProvider } from '@providers/sites'; import { CoreAppProvider } from '@providers/app'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreEventsProvider } from '@providers/events'; import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Component to display a "user avatar". @@ -48,8 +49,13 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { protected currentUserId: number; protected pictureObs; - constructor(private navCtrl: NavController, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, - private appProvider: CoreAppProvider, eventsProvider: CoreEventsProvider) { + constructor(private navCtrl: NavController, + private sitesProvider: CoreSitesProvider, + private utils: CoreUtilsProvider, + private appProvider: CoreAppProvider, + eventsProvider: CoreEventsProvider, + @Optional() private svComponent: CoreSplitViewComponent) { + this.currentUserId = this.sitesProvider.getCurrentSiteUserId(); this.pictureObs = eventsProvider.on(CoreUserProvider.PROFILE_PICTURE_UPDATED, (data) => { @@ -121,7 +127,10 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { if (this.linkProfile && this.userId) { event.preventDefault(); event.stopPropagation(); - this.navCtrl.push('CoreUserProfilePage', { userId: this.userId, courseId: this.courseId }); + + // Decide which navCtrl to use. If this component is inside a split view, use the split view's master nav. + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + navCtrl.push('CoreUserProfilePage', { userId: this.userId, courseId: this.courseId }); } } diff --git a/src/config.json b/src/config.json index 762c0709c15..30d79ac4b9b 100644 --- a/src/config.json +++ b/src/config.json @@ -2,8 +2,8 @@ "app_id": "com.moodle.moodlemobile", "appname": "Moodle Mobile", "desktopappname": "Moodle Desktop", - "versioncode": 3700, - "versionname": "3.7.0", + "versioncode": 3710, + "versionname": "3.7.1", "cache_update_frequency_usually": 420000, "cache_update_frequency_often": 1200000, "cache_update_frequency_sometimes": 3600000, @@ -58,16 +58,21 @@ "wsextservice": "local_mobile", "demo_sites": { "student": { - "url": "https:\/\/school.demo.moodle.net", + "url": "https:\/\/school.moodledemo.net", "username": "student", "password": "moodle" }, "teacher": { - "url": "https:\/\/school.demo.moodle.net", + "url": "https:\/\/school.moodledemo.net", "username": "teacher", "password": "moodle" } }, + "font_sizes": [ + 62.5, + 75.89, + 93.75 + ], "customurlscheme": "moodlemobile", "siteurl": "", "sitename": "", diff --git a/src/core/block/block.module.ts b/src/core/block/block.module.ts index 2448c6e1dd1..6361d2c2d9f 100644 --- a/src/core/block/block.module.ts +++ b/src/core/block/block.module.ts @@ -14,7 +14,9 @@ import { NgModule } from '@angular/core'; import { CoreBlockDelegate } from './providers/delegate'; +import { CoreBlockHelperProvider } from './providers/helper'; import { CoreBlockDefaultHandler } from './providers/default-block-handler'; +import { CoreBlockComponentsModule } from './components/components.module'; // List of providers (without handlers). export const CORE_BLOCK_PROVIDERS: any[] = [ @@ -24,11 +26,14 @@ export const CORE_BLOCK_PROVIDERS: any[] = [ @NgModule({ declarations: [], imports: [ + CoreBlockComponentsModule ], providers: [ CoreBlockDelegate, + CoreBlockHelperProvider, CoreBlockDefaultHandler ], exports: [] }) -export class CoreBlockModule {} +export class CoreBlockModule { +} diff --git a/src/core/block/classes/base-block-component.ts b/src/core/block/classes/base-block-component.ts index 56cbad4f6c3..46dc2c33bc5 100644 --- a/src/core/block/classes/base-block-component.ts +++ b/src/core/block/classes/base-block-component.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injector, OnInit } from '@angular/core'; +import { Injector, OnInit, Input } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; @@ -20,6 +20,13 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; * Template class to easily create components for blocks. */ export class CoreBlockBaseComponent implements OnInit { + @Input() title: string; // The block title. + @Input() block: any; // The block to render. + @Input() contextLevel: string; // The context where the block will be used. + @Input() instanceId: number; // The instance ID associated with the context level. + @Input() link: string; // Link to go when clicked. + @Input() linkParams: string; // Link params to go when clicked. + loaded: boolean; // If the component has been loaded. protected fetchContentDefaultError: string; // Default error to show when loading contents. diff --git a/src/core/block/classes/base-block-handler.ts b/src/core/block/classes/base-block-handler.ts index 85fe4999558..695d8182e79 100644 --- a/src/core/block/classes/base-block-handler.ts +++ b/src/core/block/classes/base-block-handler.ts @@ -47,7 +47,7 @@ export class CoreBlockBaseHandler implements CoreBlockHandler { * @param {number} instanceId The instance ID associated with the context level. * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, block: any, contextLevel: string, instanceId: number) + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number) : CoreBlockHandlerData | Promise { // To be overridden. diff --git a/src/core/block/components/block/block.scss b/src/core/block/components/block/block.scss index 765a266a8a4..4029d7ffac9 100644 --- a/src/core/block/components/block/block.scss +++ b/src/core/block/components/block/block.scss @@ -1,5 +1,6 @@ ion-app.app-root core-block { position: relative; + display: block; core-loading.core-loading-center { display: block; diff --git a/src/core/block/components/block/block.ts b/src/core/block/components/block/block.ts index fffba568310..eebf4fd4d08 100644 --- a/src/core/block/components/block/block.ts +++ b/src/core/block/components/block/block.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, Injector, ViewChild, OnDestroy } from '@angular/core'; +import { Component, Input, OnInit, Injector, ViewChild, OnDestroy, DoCheck, KeyValueDiffers } from '@angular/core'; import { CoreBlockDelegate } from '../../providers/delegate'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; import { Subscription } from 'rxjs'; @@ -25,7 +25,7 @@ import { CoreEventsProvider } from '@providers/events'; selector: 'core-block', templateUrl: 'core-block.html' }) -export class CoreBlockComponent implements OnInit, OnDestroy { +export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; @Input() block: any; // The block to render. @@ -33,7 +33,6 @@ export class CoreBlockComponent implements OnInit, OnDestroy { @Input() instanceId: number; // The instance ID associated with the context level. @Input() extraData: any; // Any extra data to be passed to the block. - title: string; // The title of the block. componentClass: any; // The class of the component to render. data: any = {}; // Data to pass to the component. class: string; // CSS class to apply to the block. @@ -41,8 +40,12 @@ export class CoreBlockComponent implements OnInit, OnDestroy { blockSubscription: Subscription; - constructor(protected injector: Injector, protected blockDelegate: CoreBlockDelegate, - protected eventsProvider: CoreEventsProvider) { } + protected differ: any; // To detect changes in the data input. + + constructor(protected injector: Injector, protected blockDelegate: CoreBlockDelegate, differs: KeyValueDiffers, + protected eventsProvider: CoreEventsProvider) { + this.differ = differs.find([]).create(); + } /** * Component being initialized. @@ -58,6 +61,19 @@ export class CoreBlockComponent implements OnInit, OnDestroy { this.initBlock(); } + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + if (this.data) { + // Check if there's any change in the extraData object. + const changes = this.differ.diff(this.extraData); + if (changes) { + this.data = Object.assign(this.data, this.extraData || {}); + } + } + } + /** * Get block display data and initialises the block once this is available. If the block is not * supported at the moment, try again if the available blocks are updated (because it comes @@ -80,15 +96,17 @@ export class CoreBlockComponent implements OnInit, OnDestroy { return; } - this.title = data.title; this.class = data.class; this.componentClass = data.component; // Set up the data needed by the block component. this.data = Object.assign({ + title: data.title, block: this.block, contextLevel: this.contextLevel, instanceId: this.instanceId, + link: data.link || null, + linkParams: data.linkParams || null, }, this.extraData || {}, data.componentData || {}); }).catch(() => { // Ignore errors. diff --git a/src/core/block/components/components.module.ts b/src/core/block/components/components.module.ts index 512729b8e88..70627f6bfd0 100644 --- a/src/core/block/components/components.module.ts +++ b/src/core/block/components/components.module.ts @@ -16,23 +16,39 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { IonicModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreBlockComponent } from './block/block'; +import { CoreBlockOnlyTitleComponent } from './only-title-block/only-title-block'; +import { CoreBlockPreRenderedComponent } from './pre-rendered-block/pre-rendered-block'; +import { CoreBlockCourseBlocksComponent } from './course-blocks/course-blocks'; import { CoreComponentsModule } from '@components/components.module'; @NgModule({ declarations: [ - CoreBlockComponent + CoreBlockComponent, + CoreBlockOnlyTitleComponent, + CoreBlockPreRenderedComponent, + CoreBlockCourseBlocksComponent ], imports: [ CommonModule, IonicModule, + CoreDirectivesModule, TranslateModule.forChild(), CoreComponentsModule ], providers: [ ], exports: [ - CoreBlockComponent + CoreBlockComponent, + CoreBlockOnlyTitleComponent, + CoreBlockPreRenderedComponent, + CoreBlockCourseBlocksComponent + ], + entryComponents: [ + CoreBlockOnlyTitleComponent, + CoreBlockPreRenderedComponent, + CoreBlockCourseBlocksComponent ] }) export class CoreBlockComponentsModule {} diff --git a/src/core/block/components/course-blocks/core-block-course-blocks.html b/src/core/block/components/course-blocks/core-block-course-blocks.html new file mode 100644 index 00000000000..a34ada03c63 --- /dev/null +++ b/src/core/block/components/course-blocks/core-block-course-blocks.html @@ -0,0 +1,14 @@ +
+ +
+ +
+ + + + + + + + +
diff --git a/src/core/block/components/course-blocks/course-blocks.scss b/src/core/block/components/course-blocks/course-blocks.scss new file mode 100644 index 00000000000..ef2da86c0a3 --- /dev/null +++ b/src/core/block/components/course-blocks/course-blocks.scss @@ -0,0 +1,56 @@ +$core-side-blocks-max-width: 30%; +$core-side-blocks-min-width: 280px; + +.core-course-block-with-blocks > .scroll-content { + overflow-y: visible; +} + +ion-app.app-root core-block-course-blocks { + + &.core-no-blocks .core-course-blocks-content { + height: auto; + } + + &.core-has-blocks { + @include media-breakpoint-up(md) { + display: flex; + + flex-direction: row; + flex-wrap: nowrap; + + .core-course-blocks-content { + box-shadow: none !important; + flex-grow: 1; + max-width: 100%; + } + + div.core-course-blocks-side { + max-width: $core-side-blocks-max-width; + min-width: $core-side-blocks-min-width; + @include border-start(1px, solid, $list-md-border-color); + } + + .core-course-blocks-content, + div.core-course-blocks-side { + position: relative; + height: 100%; + + .core-loading-center, + core-loading.core-loading-loaded { + position: initial; + } + } + } + + @include media-breakpoint-down(sm) { + // Disable scroll on individual columns. + div.core-course-blocks-side { + height: auto; + + &.core-hide-blocks { + display: none; + } + } + } + } +} diff --git a/src/core/block/components/course-blocks/course-blocks.ts b/src/core/block/components/course-blocks/course-blocks.ts new file mode 100644 index 00000000000..c1b849c560e --- /dev/null +++ b/src/core/block/components/course-blocks/course-blocks.ts @@ -0,0 +1,104 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChildren, Input, OnInit, QueryList, ElementRef } from '@angular/core'; +import { Content } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreBlockComponent } from '../block/block'; +import { CoreBlockHelperProvider } from '../../providers/helper'; + +/** + * Component that displays the list of course blocks. + */ +@Component({ + selector: 'core-block-course-blocks', + templateUrl: 'core-block-course-blocks.html', +}) +export class CoreBlockCourseBlocksComponent implements OnInit { + + @Input() courseId: number; + @Input() hideBlocks = false; + @Input() downloadEnabled: boolean; + + @ViewChildren(CoreBlockComponent) blocksComponents: QueryList; + + dataLoaded = false; + blocks = []; + + protected element: HTMLElement; + + constructor(private domUtils: CoreDomUtilsProvider, private courseProvider: CoreCourseProvider, + protected blockHelper: CoreBlockHelperProvider, element: ElementRef, + protected content: Content) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.loadContent().finally(() => { + this.dataLoaded = true; + }); + } + + /** + * Invalidate blocks data. + * + * @return {Promise} Promise resolved when done. + */ + invalidateBlocks(): Promise { + const promises = []; + + if (this.blockHelper.canGetCourseBlocks()) { + promises.push(this.courseProvider.invalidateCourseBlocks(this.courseId)); + } + + // Invalidate the blocks. + this.blocksComponents.forEach((blockComponent) => { + promises.push(blockComponent.invalidate().catch(() => { + // Ignore errors. + })); + }); + + return Promise.all(promises); + } + + /** + * Convenience function to fetch the data. + * + * @return {Promise} Promise resolved when done. + */ + loadContent(): Promise { + return this.blockHelper.getCourseBlocks(this.courseId).then((blocks) => { + this.blocks = blocks; + }).catch((error) => { + this.domUtils.showErrorModal(error); + + this.blocks = []; + }).finally(() => { + if (this.blocks.length > 0) { + this.element.classList.add('core-has-blocks'); + this.element.classList.remove('core-no-blocks'); + + this.content.getElementRef().nativeElement.classList.add('core-course-block-with-blocks'); + } else { + this.element.classList.remove('core-has-blocks'); + this.element.classList.add('core-no-blocks'); + this.content.getElementRef().nativeElement.classList.remove('core-course-block-with-blocks'); + } + }); + } +} diff --git a/src/core/block/components/only-title-block/core-block-only-title.html b/src/core/block/components/only-title-block/core-block-only-title.html new file mode 100644 index 00000000000..358fbde4457 --- /dev/null +++ b/src/core/block/components/only-title-block/core-block-only-title.html @@ -0,0 +1,3 @@ + +

+
\ No newline at end of file diff --git a/src/core/block/components/only-title-block/only-title-block.ts b/src/core/block/components/only-title-block/only-title-block.ts new file mode 100644 index 00000000000..2c1145003c5 --- /dev/null +++ b/src/core/block/components/only-title-block/only-title-block.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injector, OnInit, Component } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreBlockBaseComponent } from '../../classes/base-block-component'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; + +/** + * Component to render blocks with only a title and link. + */ +@Component({ + selector: 'core-block-only-title', + templateUrl: 'core-block-only-title.html' +}) +export class CoreBlockOnlyTitleComponent extends CoreBlockBaseComponent implements OnInit { + + constructor(injector: Injector, protected navCtrl: NavController, protected linkHelper: CoreContentLinksHelperProvider) { + super(injector, 'CoreBlockOnlyTitleComponent'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.fetchContentDefaultError = 'Error getting ' + this.block.contents.title + ' data.'; + } + + /** + * Go to the block page. + */ + gotoBlock(): void { + this.linkHelper.goInSite(this.navCtrl, this.link, this.linkParams, undefined, true); + } +} diff --git a/src/core/block/components/pre-rendered-block/core-block-pre-rendered.html b/src/core/block/components/pre-rendered-block/core-block-pre-rendered.html new file mode 100644 index 00000000000..84cf2ac5203 --- /dev/null +++ b/src/core/block/components/pre-rendered-block/core-block-pre-rendered.html @@ -0,0 +1,11 @@ + +

+
+ + + + + + + + diff --git a/src/core/block/components/pre-rendered-block/pre-rendered-block.ts b/src/core/block/components/pre-rendered-block/pre-rendered-block.ts new file mode 100644 index 00000000000..0acd1712f53 --- /dev/null +++ b/src/core/block/components/pre-rendered-block/pre-rendered-block.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injector, OnInit, Component } from '@angular/core'; +import { CoreBlockBaseComponent } from '../../classes/base-block-component'; + +/** + * Component to render blocks with pre-rendered HTML. + */ +@Component({ + selector: 'core-block-pre-rendered', + templateUrl: 'core-block-pre-rendered.html' +}) +export class CoreBlockPreRenderedComponent extends CoreBlockBaseComponent implements OnInit { + + constructor(injector: Injector) { + super(injector, 'CoreBlockPreRenderedComponent'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.fetchContentDefaultError = 'Error getting ' + this.block.contents.title + ' data.'; + } + +} diff --git a/src/core/block/lang/en.json b/src/core/block/lang/en.json new file mode 100644 index 00000000000..9b136b8ee2a --- /dev/null +++ b/src/core/block/lang/en.json @@ -0,0 +1,3 @@ +{ + "blocks": "Blocks" +} \ No newline at end of file diff --git a/src/core/block/providers/delegate.ts b/src/core/block/providers/delegate.ts index 47b90ecda76..7f0f245f363 100644 --- a/src/core/block/providers/delegate.ts +++ b/src/core/block/providers/delegate.ts @@ -73,6 +73,18 @@ export interface CoreBlockHandlerData { * @type {any} */ componentData?: any; + + /** + * Link to go when showing only title. + * @type {string} + */ + link?: string; + + /** + * Params of the link. + * @type {[type]} + */ + linkParams?: any; } /** @@ -105,6 +117,18 @@ export class CoreBlockDelegate extends CoreDelegate { return site.isFeatureDisabled('NoDelegate_SiteBlocks'); } + /** + * Check if blocks are disabled in a certain site for courses. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + areBlocksDisabledInCourses(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.isFeatureDisabled('NoDelegate_CourseBlocks'); + } + /** * Check if blocks are disabled in a certain site. * @@ -127,7 +151,8 @@ export class CoreBlockDelegate extends CoreDelegate { * @return {Promise} Promise resolved with the display data. */ getBlockDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number): Promise { - return Promise.resolve(this.executeFunctionOnEnabled(block.name, 'getDisplayData', [injector, block])); + return Promise.resolve(this.executeFunctionOnEnabled(block.name, 'getDisplayData', + [injector, block, contextLevel, instanceId])); } /** diff --git a/src/core/block/providers/helper.ts b/src/core/block/providers/helper.ts new file mode 100644 index 00000000000..14473f757aa --- /dev/null +++ b/src/core/block/providers/helper.ts @@ -0,0 +1,59 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; + +/** + * Service that provides helper functions for blocks. + */ +@Injectable() +export class CoreBlockHelperProvider { + + constructor(protected courseProvider: CoreCourseProvider, protected blockDelegate: CoreBlockDelegate) {} + + /** + * Return if it get course blocks options is enabled for the current site. + * + * @return {boolean} true if enabled, false otherwise. + */ + canGetCourseBlocks(): boolean { + return this.courseProvider.canGetCourseBlocks() && !this.blockDelegate.areBlocksDisabledInCourses(); + } + + /** + * Returns the list of blocks for the selected course. + * + * @param {number} courseId Course ID. + * @return {Promise} List of supported blocks. + */ + getCourseBlocks(courseId: number): Promise { + const canGetBlocks = this.canGetCourseBlocks(); + + if (!canGetBlocks) { + return Promise.resolve([]); + } + + return this.courseProvider.getCourseBlocks(courseId).then((blocks) => { + const hasSupportedBlock = this.blockDelegate.hasSupportedBlock(blocks); + + if (!hasSupportedBlock) { + return []; + } + + return blocks; + }); + } +} diff --git a/src/core/comments/comments.module.ts b/src/core/comments/comments.module.ts index 980e458b8aa..d19d2ccf34e 100644 --- a/src/core/comments/comments.module.ts +++ b/src/core/comments/comments.module.ts @@ -13,7 +13,12 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreCommentsProvider } from './providers/comments'; +import { CoreCommentsOfflineProvider } from './providers/offline'; +import { CoreCommentsSyncCronHandler } from './providers/sync-cron-handler'; +import { CoreCommentsSyncProvider } from './providers/sync'; @NgModule({ declarations: [ @@ -21,7 +26,20 @@ import { CoreCommentsProvider } from './providers/comments'; imports: [ ], providers: [ - CoreCommentsProvider + CoreCommentsProvider, + CoreCommentsOfflineProvider, + CoreCommentsSyncProvider, + CoreCommentsSyncCronHandler ] }) -export class CoreCommentsModule {} +export class CoreCommentsModule { + constructor(eventsProvider: CoreEventsProvider, cronDelegate: CoreCronDelegate, syncHandler: CoreCommentsSyncCronHandler) { + // Reset comments page size. + eventsProvider.on(CoreEventsProvider.LOGIN, () => { + CoreCommentsProvider.pageSize = null; + CoreCommentsProvider.pageSizeOK = false; + }); + + cronDelegate.register(syncHandler); + } +} diff --git a/src/core/comments/components/comments/comments.scss b/src/core/comments/components/comments/comments.scss new file mode 100644 index 00000000000..cd7b0655a5c --- /dev/null +++ b/src/core/comments/components/comments/comments.scss @@ -0,0 +1,4 @@ +core-comments .core-comments-clickable { + pointer-events: auto; + cursor: pointer; +} \ No newline at end of file diff --git a/src/core/comments/components/comments/comments.ts b/src/core/comments/components/comments/comments.ts index 6538a087421..6bbe34fc45d 100644 --- a/src/core/comments/components/comments/comments.ts +++ b/src/core/comments/components/comments/comments.ts @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChange } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChange, Optional } from '@angular/core'; import { NavController } from 'ionic-angular'; import { CoreCommentsProvider } from '../../providers/comments'; import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Component that displays the count of comments. @@ -31,19 +32,22 @@ export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { @Input() component: string; @Input() itemId: number; @Input() area = ''; - @Input() page = 0; @Input() title?: string; @Input() displaySpinner = true; // Whether to display the loading spinner. @Output() onLoading: EventEmitter; // Eevent that indicates whether the component is loading data. commentsLoaded = false; - commentsCount: number; + commentsCount: string; + countError = false; disabled = false; protected updateSiteObserver; + protected refreshCommentsObserver; constructor(private navCtrl: NavController, private commentsProvider: CoreCommentsProvider, - sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider) { + sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + @Optional() private svComponent: CoreSplitViewComponent) { + this.onLoading = new EventEmitter(); this.disabled = this.commentsProvider.areCommentsDisabledInSite(); @@ -58,6 +62,19 @@ export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { this.fetchData(); } }, sitesProvider.getCurrentSiteId()); + + // Refresh comments if event received. + this.refreshCommentsObserver = eventsProvider.on(CoreCommentsProvider.REFRESH_COMMENTS_EVENT, (data) => { + // Verify these comments need to be updated. + if (this.undefinedOrEqual(data, 'contextLevel') && this.undefinedOrEqual(data, 'instanceId') && + this.undefinedOrEqual(data, 'component') && this.undefinedOrEqual(data, 'itemId') && + this.undefinedOrEqual(data, 'area')) { + + this.doRefresh().catch(() => { + // Ignore errors. + }); + } + }, sitesProvider.getCurrentSiteId()); } /** @@ -72,12 +89,15 @@ export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { */ ngOnChanges(changes: { [name: string]: SimpleChange }): void { // If something change, update the fields. - if (changes) { + if (changes && this.commentsLoaded) { this.fetchData(); } } - protected fetchData(): void { + /** + * Fetch comments data. + */ + fetchData(): void { if (this.disabled) { return; } @@ -85,30 +105,55 @@ export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { this.commentsLoaded = false; this.onLoading.emit(true); - this.commentsProvider.getComments(this.contextLevel, this.instanceId, this.component, this.itemId, this.area, this.page) - .then((comments) => { - this.commentsCount = comments && comments.length ? comments.length : 0; - }).catch(() => { - this.commentsCount = -1; - }).finally(() => { - this.commentsLoaded = true; - this.onLoading.emit(false); - }); + this.commentsProvider.getCommentsCount(this.contextLevel, this.instanceId, this.component, this.itemId, this.area) + .then((commentsCount) => { + this.commentsCount = commentsCount; + this.countError = parseInt(this.commentsCount, 10) < 0; + this.commentsLoaded = true; + this.onLoading.emit(false); + }); + } + + /** + * Refresh comments. + * + * @return {Promise} Promise resolved when done. + */ + doRefresh(): Promise { + return this.invalidateComments().then(() => { + return this.fetchData(); + }); + } + + /** + * Invalidate comments data. + * + * @return {Promise} Promise resolved when done. + */ + invalidateComments(): Promise { + return this.commentsProvider.invalidateCommentsData(this.contextLevel, this.instanceId, this.component, this.itemId, + this.area); } /** * Opens the comments page. */ - openComments(): void { - if (!this.disabled && this.commentsCount > 0) { + openComments(e?: Event): void { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + if (!this.disabled && !this.countError) { // Open a new state with the interpolated contents. - this.navCtrl.push('CoreCommentsViewerPage', { + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + + navCtrl.push('CoreCommentsViewerPage', { contextLevel: this.contextLevel, instanceId: this.instanceId, - component: this.component, + componentName: this.component, itemId: this.itemId, area: this.area, - page: this.page, title: this.title, }); } @@ -119,5 +164,17 @@ export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { */ ngOnDestroy(): void { this.updateSiteObserver && this.updateSiteObserver.off(); + this.refreshCommentsObserver && this.refreshCommentsObserver.off(); + } + + /** + * Check if a certain value in data is undefined or equal to this instance value. + * + * @param {any} data Data object. + * @param {string} name Name of the property to check. + * @return {boolean} Whether it's undefined or equal. + */ + protected undefinedOrEqual(data: any, name: string): boolean { + return typeof data[name] == 'undefined' || data[name] == this[name]; } } diff --git a/src/core/comments/components/comments/core-comments.html b/src/core/comments/components/comments/core-comments.html index e7b71e041d0..4375edc5ace 100644 --- a/src/core/comments/components/comments/core-comments.html +++ b/src/core/comments/components/comments/core-comments.html @@ -1,8 +1,8 @@ -
- {{ 'core.commentscount' | translate : {'$a': commentsCount} }} +
+ {{ 'core.comments.commentscount' | translate : {'$a': commentsCount} }}
-
- {{ 'core.commentsnotworking' | translate }} +
+ {{ 'core.comments.commentsnotworking' | translate }}
diff --git a/src/core/comments/lang/en.json b/src/core/comments/lang/en.json new file mode 100644 index 00000000000..c48dcce1716 --- /dev/null +++ b/src/core/comments/lang/en.json @@ -0,0 +1,12 @@ +{ + "addcomment": "Add a comment...", + "comments": "Comments", + "commentscount": "Comments ({{$a}})", + "commentsnotworking": "Comments cannot be retrieved", + "deletecommentbyon": "Delete comment posted by {{$a.user}} on {{$a.time}}", + "eventcommentcreated": "Comment created", + "eventcommentdeleted": "Comment deleted", + "nocomments": "No comments", + "savecomment": "Save comment", + "warningcommentsnotsent": "Couldn't sync comments. {{error}}" +} \ No newline at end of file diff --git a/src/core/comments/pages/add/add.html b/src/core/comments/pages/add/add.html new file mode 100644 index 00000000000..b5ca49440af --- /dev/null +++ b/src/core/comments/pages/add/add.html @@ -0,0 +1,22 @@ + + + {{ 'core.comments.addcomment' | translate }} + + + + + + +
+ + + +
+ +
+
+
diff --git a/src/core/comments/pages/add/add.module.ts b/src/core/comments/pages/add/add.module.ts new file mode 100644 index 00000000000..a6b6661a019 --- /dev/null +++ b/src/core/comments/pages/add/add.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { CoreCommentsAddPage } from './add'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreCommentsAddPage + ], + imports: [ + CoreDirectivesModule, + IonicPageModule.forChild(CoreCommentsAddPage), + TranslateModule.forChild() + ] +}) +export class CoreCommentsAddPageModule {} diff --git a/src/core/comments/pages/add/add.ts b/src/core/comments/pages/add/add.ts new file mode 100644 index 00000000000..66510fb7979 --- /dev/null +++ b/src/core/comments/pages/add/add.ts @@ -0,0 +1,82 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; +import { CoreAppProvider } from '@providers/app'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreCommentsProvider } from '../../providers/comments'; + +/** + * Component that displays a text area for composing a comment. + */ +@IonicPage({ segment: 'core-comments-add' }) +@Component({ + selector: 'page-core-comments-add', + templateUrl: 'add.html', +}) +export class CoreCommentsAddPage { + protected contextLevel: string; + protected instanceId: number; + protected componentName: string; + protected itemId: number; + protected area = ''; + + content = ''; + processing = false; + + constructor(params: NavParams, private viewCtrl: ViewController, private appProvider: CoreAppProvider, + private domUtils: CoreDomUtilsProvider, private commentsProvider: CoreCommentsProvider) { + this.contextLevel = params.get('contextLevel'); + this.instanceId = params.get('instanceId'); + this.componentName = params.get('componentName'); + this.itemId = params.get('itemId'); + this.area = params.get('area') || ''; + this.content = params.get('content') || ''; + } + + /** + * Send the comment or store it offline. + * + * @param {Event} e Event. + */ + addComment(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + this.appProvider.closeKeyboard(); + const loadingModal = this.domUtils.showModalLoading('core.sending', true); + // Freeze the add comment button. + this.processing = true; + this.commentsProvider.addComment(this.content, this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area).then((commentsResponse) => { + this.viewCtrl.dismiss({comments: commentsResponse}).finally(() => { + this.domUtils.showToast(commentsResponse ? 'core.comments.eventcommentcreated' : 'core.datastoredoffline', true, + 3000); + }); + }).catch((error) => { + this.domUtils.showErrorModal(error); + this.processing = false; + }).finally(() => { + loadingModal.dismiss(); + }); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } +} diff --git a/src/core/comments/pages/viewer/viewer.html b/src/core/comments/pages/viewer/viewer.html index bcd909ecdf3..2a023a41992 100644 --- a/src/core/comments/pages/viewer/viewer.html +++ b/src/core/comments/pages/viewer/viewer.html @@ -1,24 +1,71 @@ + + + + + + + - + - + + +
+ + {{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }} +
+ + + + +

{{ offlineComment.fullname }}

+

+ {{ 'core.notsent' | translate }} +

+ +
+ + + +

{{ comment.fullname }}

-

{{ comment.time }}

+

{{ comment.timecreated * 1000 | coreFormatDate: 'strftimerecentfull' }}

+

+ {{ 'core.deletedoffline' | translate }} +

+ +
+ +
+ + + +
diff --git a/src/core/comments/pages/viewer/viewer.module.ts b/src/core/comments/pages/viewer/viewer.module.ts index ca526797011..3326cfe19aa 100644 --- a/src/core/comments/pages/viewer/viewer.module.ts +++ b/src/core/comments/pages/viewer/viewer.module.ts @@ -18,6 +18,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreCommentsViewerPage } from './viewer'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCommentsComponentsModule } from '../../components/components.module'; @NgModule({ @@ -27,6 +28,7 @@ import { CoreCommentsComponentsModule } from '../../components/components.module imports: [ CoreComponentsModule, CoreDirectivesModule, + CorePipesModule, CoreCommentsComponentsModule, IonicPageModule.forChild(CoreCommentsViewerPage), TranslateModule.forChild() diff --git a/src/core/comments/pages/viewer/viewer.ts b/src/core/comments/pages/viewer/viewer.ts index 6652b879b7f..e84d27e0c73 100644 --- a/src/core/comments/pages/viewer/viewer.ts +++ b/src/core/comments/pages/viewer/viewer.ts @@ -12,13 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild } from '@angular/core'; -import { IonicPage, Content, NavParams } from 'ionic-angular'; +import { Component, ViewChild, OnDestroy } from '@angular/core'; +import { IonicPage, Content, NavParams, ModalController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { coreSlideInOut } from '@classes/animations'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreEventsProvider } from '@providers/events'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreCommentsProvider } from '../../providers/comments'; +import { CoreCommentsOfflineProvider } from '../../providers/offline'; +import { CoreCommentsSyncProvider } from '../../providers/sync'; /** * Page that displays comments. @@ -27,81 +33,333 @@ import { CoreCommentsProvider } from '../../providers/comments'; @Component({ selector: 'page-core-comments-viewer', templateUrl: 'viewer.html', + animations: [coreSlideInOut] }) -export class CoreCommentsViewerPage { +export class CoreCommentsViewerPage implements OnDestroy { @ViewChild(Content) content: Content; comments = []; commentsLoaded = false; contextLevel: string; instanceId: number; - component: string; + componentName: string; itemId: number; area: string; page: number; title: string; + canLoadMore = false; + loadMoreError = false; + canAddComments = false; + canDeleteComments = false; + showDelete = false; + hasOffline = false; + refreshIcon = 'spinner'; + syncIcon = 'spinner'; + offlineComment: any; + currentUserId: number; - constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, private userProvider: CoreUserProvider, - private domUtils: CoreDomUtilsProvider, private translate: TranslateService, - private commentsProvider: CoreCommentsProvider) { + protected addDeleteCommentsAvailable = false; + protected syncObserver: any; + protected currentUser: any; + + constructor(navParams: NavParams, private sitesProvider: CoreSitesProvider, private userProvider: CoreUserProvider, + private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private modalCtrl: ModalController, + private commentsProvider: CoreCommentsProvider, private offlineComments: CoreCommentsOfflineProvider, + eventsProvider: CoreEventsProvider, private commentsSync: CoreCommentsSyncProvider, + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { this.contextLevel = navParams.get('contextLevel'); this.instanceId = navParams.get('instanceId'); - this.component = navParams.get('component'); + this.componentName = navParams.get('componentName'); this.itemId = navParams.get('itemId'); this.area = navParams.get('area') || ''; - this.page = navParams.get('page') || 0; - this.title = navParams.get('title') || this.translate.instant('core.comments'); + this.title = navParams.get('title') || this.translate.instant('core.comments.comments'); + this.page = 0; + + // Refresh data if comments are synchronized automatically. + this.syncObserver = eventsProvider.on(CoreCommentsSyncProvider.AUTO_SYNCED, (data) => { + if (data.contextLevel == this.contextLevel && data.instanceId == this.instanceId && + data.componentName == this.componentName && data.itemId == this.itemId && data.area == this.area) { + // Show the sync warnings. + this.showSyncWarnings(data.warnings); + + // Refresh the data. + this.commentsLoaded = false; + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + this.domUtils.scrollToTop(this.content); + + this.page = 0; + this.comments = []; + this.fetchComments(false); + } + }, sitesProvider.getCurrentSiteId()); } /** * View loaded. */ ionViewDidLoad(): void { - this.fetchComments().finally(() => { - this.commentsLoaded = true; + this.commentsProvider.isAddCommentsAvailable().then((enabled) => { + // Is implicit the user can delete if he can add. + this.addDeleteCommentsAvailable = enabled; }); + + this.currentUserId = this.sitesProvider.getCurrentSiteUserId(); + this.fetchComments(true); } /** * Fetches the comments. * + * @param {boolean} sync When to resync comments. + * @param {boolean} [showErrors] When to display errors or not. * @return {Promise} Resolved when done. */ - protected fetchComments(): Promise { - // Get comments data. - return this.commentsProvider.getComments(this.contextLevel, this.instanceId, this.component, this.itemId, - this.area, this.page).then((comments) => { - this.comments = comments; - this.comments.sort((a, b) => b.timecreated - a.timecreated); - this.comments.forEach((comment) => { - // Get the user profile image. - this.userProvider.getProfile(comment.userid, undefined, true).then((user) => { - comment.profileimageurl = user.profileimageurl; - }).catch(() => { - // Ignore errors. + protected fetchComments(sync: boolean, showErrors?: boolean): Promise { + this.loadMoreError = false; + + const promise = sync ? this.syncComments(showErrors) : Promise.resolve(); + + return promise.catch(() => { + // Ignore errors. + }).then(() => { + return this.offlineComments.getComment(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area).then((offlineComment) => { + this.offlineComment = offlineComment; + + if (offlineComment && !this.currentUser) { + return this.userProvider.getProfile(this.currentUserId, undefined, true).then((user) => { + this.currentUser = user; + this.offlineComment.profileimageurl = user.profileimageurl; + this.offlineComment.fullname = user.fullname; + this.offlineComment.userid = user.id; + }).catch(() => { + // Ignore errors. + }); + } else if (offlineComment) { + this.offlineComment.profileimageurl = this.currentUser.profileimageurl; + this.offlineComment.fullname = this.currentUser.fullname; + this.offlineComment.userid = this.currentUser.id; + } + + return this.offlineComments.getDeletedComments(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area); + }); + }).then((deletedComments) => { + this.hasOffline = !!this.offlineComment || deletedComments.length > 0; + + // Get comments data. + return this.commentsProvider.getComments(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area, this.page).then((response) => { + this.canAddComments = this.addDeleteCommentsAvailable && response.canpost; + + const comments = response.comments.sort((a, b) => b.timecreated - a.timecreated); + this.canLoadMore = comments.length > 0 && comments.length >= CoreCommentsProvider.pageSize; + + return Promise.all(comments.map((comment) => { + // Get the user profile image. + return this.userProvider.getProfile(comment.userid, undefined, true).then((user) => { + comment.profileimageurl = user.profileimageurl; + + return comment; + }).catch(() => { + // Ignore errors. + return comment; + }); + })); + }).then((comments) => { + this.comments = this.comments.concat(comments); + + deletedComments && deletedComments.forEach((deletedComment) => { + const comment = this.comments.find((comment) => { + return comment.id == deletedComment.commentid; + }); + + if (comment) { + comment.deleted = deletedComment.deleted; + } }); + + this.canDeleteComments = this.addDeleteCommentsAvailable && (this.hasOffline || this.comments.some((comment) => { + return !!comment.delete; + })); }); }).catch((error) => { - if (error && this.component == 'assignsubmission_comments') { - this.domUtils.showAlertTranslated('core.notice', 'core.commentsnotworking'); + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + if (error && this.componentName == 'assignsubmission_comments') { + this.domUtils.showAlertTranslated('core.notice', 'core.comments.commentsnotworking'); } else { this.domUtils.showErrorModalDefault(error, this.translate.instant('core.error') + ': get_comments'); } + }).finally(() => { + this.commentsLoaded = true; + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; + }); + + } + + /** + * Function to load more commemts. + * + * @param {any} [infiniteComplete] Infinite scroll complete function. Only used from core-infinite-loading. + * @return {Promise} Resolved when done. + */ + loadMore(infiniteComplete?: any): Promise { + this.page++; + this.canLoadMore = false; + + return this.fetchComments(true).finally(() => { + infiniteComplete && infiniteComplete(); }); } /** * Refresh the comments. * - * @param {any} refresher Refresher. + * @param {boolean} showErrors Whether to display errors or not. + * @param {any} [refresher] Refresher. + * @return {Promise} Resolved when done. */ - refreshComments(refresher: any): void { - this.commentsProvider.invalidateCommentsData(this.contextLevel, this.instanceId, this.component, - this.itemId, this.area, this.page).finally(() => { - return this.fetchComments().finally(() => { - refresher.complete(); + refreshComments(showErrors: boolean, refresher?: any): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + return this.commentsProvider.invalidateCommentsData(this.contextLevel, this.instanceId, this.componentName, + this.itemId, this.area).finally(() => { + this.page = 0; + this.comments = []; + + return this.fetchComments(true, showErrors).finally(() => { + refresher && refresher.complete(); }); }); } + + /** + * Show sync warnings if any. + * + * @param {string[]} warnings the warnings + */ + private showSyncWarnings(warnings: string[]): void { + const message = this.textUtils.buildMessage(warnings); + if (message) { + this.domUtils.showErrorModal(message); + } + } + + /** + * Tries to synchronize comments. + * + * @param {boolean} showErrors Whether to display errors or not. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + private syncComments(showErrors: boolean): Promise { + return this.commentsSync.syncComments(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area).then((warnings) => { + this.showSyncWarnings(warnings); + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + + return Promise.reject(null); + }); + } + + /** + * Add a new comment to the list. + * + * @param {Event} e Event. + */ + addComment(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + const params = { + contextLevel: this.contextLevel, + instanceId: this.instanceId, + componentName: this.componentName, + itemId: this.itemId, + area: this.area, + content: this.hasOffline ? this.offlineComment.content : '' + }; + + const modal = this.modalCtrl.create('CoreCommentsAddPage', params); + modal.onDidDismiss((data) => { + if (data && data.comments) { + this.comments = data.comments.concat(this.comments); + this.canDeleteComments = this.addDeleteCommentsAvailable; + } else if (data && !data.comments) { + this.fetchComments(false); + } + }); + modal.present(); + } + + /** + * Delete a comment. + * + * @param {Event} e Click event. + * @param {any} comment Comment to delete. + */ + deleteComment(e: Event, comment: any): void { + e.preventDefault(); + e.stopPropagation(); + + const time = this.timeUtils.userDate((comment.lastmodified || comment.timecreated) * 1000, 'core.strftimerecentfull'); + + comment.contextlevel = this.contextLevel; + comment.instanceid = this.instanceId; + comment.component = this.componentName; + comment.itemid = this.itemId; + comment.area = this.area; + + this.domUtils.showConfirm(this.translate.instant('core.comments.deletecommentbyon', {$a: + { user: comment.fullname || '', time: time } })).then(() => { + this.commentsProvider.deleteComment(comment).then(() => { + this.showDelete = false; + + this.refreshComments(true); + + this.domUtils.showToast('core.comments.eventcommentdeleted', true, 3000); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Delete comment failed.'); + }); + }).catch(() => { + // User cancelled, nothing to do. + }); + } + + /** + * Restore a comment. + * + * @param {Event} e Click event. + * @param {any} comment Comment to delete. + */ + undoDeleteComment(e: Event, comment: any): void { + e.preventDefault(); + e.stopPropagation(); + + this.offlineComments.undoDeleteComment(comment.id).then(() => { + comment.deleted = false; + this.showDelete = false; + }); + } + + /** + * Toggle delete. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.syncObserver && this.syncObserver.off(); + } } diff --git a/src/core/comments/providers/comments.ts b/src/core/comments/providers/comments.ts index 9808a0c8323..16d7c8f9527 100644 --- a/src/core/comments/providers/comments.ts +++ b/src/core/comments/providers/comments.ts @@ -13,8 +13,11 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSite } from '@classes/site'; +import { CoreCommentsOfflineProvider } from './offline'; /** * Service that provides some features regarding comments. @@ -22,9 +25,113 @@ import { CoreSite } from '@classes/site'; @Injectable() export class CoreCommentsProvider { + static REFRESH_COMMENTS_EVENT = 'core_comments_refresh_comments'; + protected ROOT_CACHE_KEY = 'mmComments:'; + static pageSize = null; + static pageSizeOK = false; // If true, the pageSize is definitive. If not, it's a temporal value to reduce WS calls. - constructor(private sitesProvider: CoreSitesProvider) {} + constructor(private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, + private commentsOffline: CoreCommentsOfflineProvider) {} + + /** + * Add a comment. + * + * @param {string} content Comment text. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if comment was sent to server, false if stored in device. + */ + addComment(content: string, contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a comment to be synchronized later. + const storeOffline = (): Promise => { + return this.commentsOffline.saveComment(content, contextLevel, instanceId, component, itemId, area, siteId).then(() => { + return Promise.resolve(false); + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the comment. + return storeOffline(); + } + + // Send comment to server. + return this.addCommentOnline(content, contextLevel, instanceId, component, itemId, area, siteId).then((comments) => { + return comments; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + return Promise.reject(error); + } + + // Error sending comment, store it to retry later. + return storeOffline(); + }); + } + + /** + * Add a comment. It will fail if offline or cannot connect. + * + * @param {string} content Comment text. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when added, rejected otherwise. + */ + addCommentOnline(content: string, contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = '', siteId?: string): Promise { + const comments = [ + { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + content: content + } + ]; + + return this.addCommentsOnline(comments, siteId).then((commentsResponse) => { + // A cooment was added, invalidate them. + return this.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return commentsResponse; + }); + }); + } + + /** + * Add several comments. It will fail if offline or cannot connect. + * + * @param {any[]} comments Comments to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when added, rejected otherwise. Promise resolved doesn't mean that comments + * have been added, the resolve param can contain errors for comments not sent. + */ + addCommentsOnline(comments: any[], siteId?: string): Promise { + if (!comments || !comments.length) { + return Promise.resolve(); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + comments: comments + }; + + return site.write('core_comment_add_comments', data); + }); + } /** * Check if Calendar is disabled in a certain site. @@ -50,6 +157,97 @@ export class CoreCommentsProvider { }); } + /** + * Delete a comment. + * + * @param {any} comment Comment object to delete. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that comments + * have been deleted, the resolve param can contain errors for comments not deleted. + */ + deleteComment(comment: any, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!comment.id) { + return this.commentsOffline.removeComment(comment.contextlevel, comment.instanceid, comment.component, comment.itemid, + comment.area, siteId); + } + + // Convenience function to store the action to be synchronized later. + const storeOffline = (): Promise => { + return this.commentsOffline.deleteComment(comment.id, comment.contextlevel, comment.instanceid, comment.component, + comment.itemid, comment.area, siteId).then(() => { + return false; + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the comment. + return storeOffline(); + } + + // Send comment to server. + return this.deleteCommentsOnline([comment.id], comment.contextlevel, comment.instanceid, comment.component, comment.itemid, + comment.area, siteId).then(() => { + return true; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the comment so don't store it. + return Promise.reject(error); + } + + // Error sending comment, store it to retry later. + return storeOffline(); + }); + } + + /** + * Delete a comment. It will fail if offline or cannot connect. + * + * @param {number[]} commentIds Comment IDs to delete. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that comments + * have been deleted, the resolve param can contain errors for comments not deleted. + */ + deleteCommentsOnline(commentIds: number[], contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = '', siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + comments: commentIds + }; + + return site.write('core_comment_delete_comments', data).then((response) => { + // A comment was deleted, invalidate comments. + return this.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId).catch(() => { + // Ignore errors. + }); + }); + }); + } + + /** + * Returns whether WS to add/delete comments are available in site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if available, resolved with false or rejected otherwise. + * @since 3.8 + */ + isAddCommentsAvailable(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // First check if it's disabled. + if (this.areCommentsDisabledInSite(site)) { + return false; + } + + return site.wsAvailable('core_comment_add_comments'); + }); + } + /** * Get cache key for get comments data WS calls. * @@ -58,12 +256,11 @@ export class CoreCommentsProvider { * @param {string} component Component name. * @param {number} itemId Associated id. * @param {string} [area=''] String comment area. Default empty. - * @param {number} [page=0] Page number (0 based). Default 0. * @return {string} Cache key. */ - protected getCommentsCacheKey(contextLevel: string, instanceId: number, component: string, - itemId: number, area: string = '', page: number = 0): string { - return this.getCommentsPrefixCacheKey(contextLevel, instanceId) + ':' + component + ':' + itemId + ':' + area + ':' + page; + protected getCommentsCacheKey(contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = ''): string { + return this.getCommentsPrefixCacheKey(contextLevel, instanceId) + ':' + component + ':' + itemId + ':' + area; } /** @@ -89,8 +286,8 @@ export class CoreCommentsProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the comments. */ - getComments(contextLevel: string, instanceId: number, component: string, itemId: number, - area: string = '', page: number = 0, siteId?: string): Promise { + getComments(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', page: number = 0, + siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params: any = { contextlevel: contextLevel, @@ -102,13 +299,13 @@ export class CoreCommentsProvider { }; const preSets = { - cacheKey: this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area, page), + cacheKey: this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area), updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_comment_get_comments', params, preSets).then((response) => { if (response.comments) { - return response.comments; + return response; } return Promise.reject(null); @@ -116,6 +313,61 @@ export class CoreCommentsProvider { }); } + /** + * Get comments count number to show on the comments component. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Comments count with plus sign if needed. + */ + getCommentsCount(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + + siteId = siteId ? siteId : this.sitesProvider.getCurrentSiteId(); + + // Convenience function to get comments number on a page. + const getCommentsPageCount = (page: number): Promise => { + return this.getComments(contextLevel, instanceId, component, itemId, area, page, siteId).then((response) => { + if (response.comments) { + // Update pageSize with the greatest count at the moment. + if (response.comments && response.comments.length > CoreCommentsProvider.pageSize) { + CoreCommentsProvider.pageSize = response.comments.length; + } + + return response.comments && response.comments.length ? response.comments.length : 0; + } + + return -1; + }).catch(() => { + return -1; + }); + }; + + return getCommentsPageCount(0).then((count) => { + if (CoreCommentsProvider.pageSizeOK && count >= CoreCommentsProvider.pageSize) { + // Page Size is ok, show + in case it reached the limit. + return (CoreCommentsProvider.pageSize - 1) + '+'; + } else if (count < 0 || (CoreCommentsProvider.pageSize && count < CoreCommentsProvider.pageSize)) { + return count + ''; + } + + // Call to update page size. + return getCommentsPageCount(1).then((countMore) => { + // Page limit was reached on the previous call. + if (countMore > 0) { + + return (CoreCommentsProvider.pageSize - 1) + '+'; + } + + return count + ''; + }); + }); + } + /** * Invalidates comments data. * @@ -124,14 +376,20 @@ export class CoreCommentsProvider { * @param {string} component Component name. * @param {number} itemId Associated id. * @param {string} [area=''] String comment area. Default empty. - * @param {number} [page=0] Page number (0 based). Default 0. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the data is invalidated. */ invalidateCommentsData(contextLevel: string, instanceId: number, component: string, itemId: number, - area: string = '', page: number = 0, siteId?: string): Promise { + area: string = '', siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.invalidateWsCacheForKey(this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area, page)); + + return this.utils.allPromises([ + // This is done with starting with to avoid conflicts with previous keys that were including page. + site.invalidateWsCacheForKeyStartingWith(this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, + area) + ':'), + + site.invalidateWsCacheForKey(this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area)) + ]); }); } diff --git a/src/core/comments/providers/offline.ts b/src/core/comments/providers/offline.ts new file mode 100644 index 00000000000..94caf9fb336 --- /dev/null +++ b/src/core/comments/providers/offline.ts @@ -0,0 +1,338 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; + +/** + * Service to handle offline comments. + */ +@Injectable() +export class CoreCommentsOfflineProvider { + + // Variables for database. + static COMMENTS_TABLE = 'core_comments_offline_comments'; + static COMMENTS_DELETED_TABLE = 'core_comments_deleted_offline_comments'; + protected siteSchema: CoreSiteSchema = { + name: 'CoreCommentsOfflineProvider', + version: 1, + tables: [ + { + name: CoreCommentsOfflineProvider.COMMENTS_TABLE, + columns: [ + { + name: 'contextlevel', + type: 'TEXT' + }, + { + name: 'instanceid', + type: 'INTEGER' + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'itemid', + type: 'INTEGER' + }, + { + name: 'area', + type: 'TEXT' + }, + { + name: 'content', + type: 'TEXT' + }, + { + name: 'lastmodified', + type: 'INTEGER' + } + ], + primaryKeys: ['contextlevel', 'instanceid', 'component', 'itemid', 'area'] + }, + { + name: CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE, + columns: [ + { + name: 'commentid', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'contextlevel', + type: 'TEXT' + }, + { + name: 'instanceid', + type: 'INTEGER' + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'itemid', + type: 'INTEGER' + }, + { + name: 'area', + type: 'TEXT' + }, + { + name: 'deleted', + type: 'INTEGER' + } + ] + } + ] + }; + + constructor( private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Get all offline comments. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with comments. + */ + getAllComments(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return Promise.all([site.getDb().getRecords(CoreCommentsOfflineProvider.COMMENTS_TABLE), + site.getDb().getRecords(CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE)]).then((results) => { + return [].concat.apply([], results); + }); + }); + } + + /** + * Get an offline comment. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the comments. + */ + getComment(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(CoreCommentsOfflineProvider.COMMENTS_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area + }); + }).catch(() => { + return false; + }); + } + + /** + * Get all offline comments added or deleted of a special area. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the comments. + */ + getComments(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + let comments = []; + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getComment(contextLevel, instanceId, component, itemId, area, siteId).then((comment) => { + comments = comment ? [comment] : []; + + return this.getDeletedComments(contextLevel, instanceId, component, itemId, area, siteId); + }).then((deletedComments) => { + comments = comments.concat(deletedComments); + + return comments; + }); + } + + /** + * Get all offline deleted comments. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with comments. + */ + getAllDeletedComments(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE); + }); + } + + /** + * Get an offline comment. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the comments. + */ + getDeletedComments(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area + }); + }).catch(() => { + return false; + }); + } + + /** + * Remove an offline comment. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + removeComment(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(CoreCommentsOfflineProvider.COMMENTS_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area + }); + }); + } + + /** + * Remove an offline deleted comment. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + removeDeletedComments(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area + }); + }); + } + + /** + * Save a comment to be sent later. + * + * @param {string} content Comment text. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + saveComment(content: string, contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = '', siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const now = this.timeUtils.timestamp(); + const data = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + content: content, + lastmodified: now + }; + + return site.getDb().insertRecord(CoreCommentsOfflineProvider.COMMENTS_TABLE, data).then(() => { + return data; + }); + }); + } + + /** + * Delete a comment offline to be sent later. + * + * @param {number} commentId Comment ID. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + deleteComment(commentId: number, contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = '', siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const now = this.timeUtils.timestamp(); + const data = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + commentid: commentId, + deleted: now + }; + + return site.getDb().insertRecord(CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE, data).then(() => { + return data; + }); + }); + } + + /** + * Undo delete a comment. + * + * @param {number} commentId Comment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + undoDeleteComment(commentId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(CoreCommentsOfflineProvider.COMMENTS_DELETED_TABLE, { commentid: commentId }); + }); + } +} diff --git a/src/core/comments/providers/sync-cron-handler.ts b/src/core/comments/providers/sync-cron-handler.ts new file mode 100644 index 00000000000..5803f936e50 --- /dev/null +++ b/src/core/comments/providers/sync-cron-handler.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { CoreCommentsSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class CoreCommentsSyncCronHandler implements CoreCronHandler { + name = 'CoreCommentsSyncCronHandler'; + + constructor(private commentsSync: CoreCommentsSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return this.commentsSync.syncAllComments(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } +} diff --git a/src/core/comments/providers/sync.ts b/src/core/comments/providers/sync.ts new file mode 100644 index 00000000000..c8466cac708 --- /dev/null +++ b/src/core/comments/providers/sync.ts @@ -0,0 +1,229 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreAppProvider } from '@providers/app'; +import { CoreCommentsOfflineProvider } from './offline'; +import { CoreCommentsProvider } from './comments'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync omments. + */ +@Injectable() +export class CoreCommentsSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'core_comments_autom_synced'; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + private commentsOffline: CoreCommentsOfflineProvider, private utils: CoreUtilsProvider, + private eventsProvider: CoreEventsProvider, private commentsProvider: CoreCommentsProvider, + timeUtils: CoreTimeUtilsProvider) { + + super('CoreCommentsSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); + } + + /** + * Try to synchronize all the comments in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllComments(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all comments', this.syncAllCommentsFunc.bind(this), [force], siteId); + } + + /** + * Synchronize all the comments in a certain site + * + * @param {string} siteId Site ID to sync. + * @param {boolean} force Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + private syncAllCommentsFunc(siteId: string, force: boolean): Promise { + return this.commentsOffline.getAllComments(siteId).then((comments) => { + + // Get Unique array. + comments.forEach((comment) => { + comment.syncId = this.getSyncId(comment.contextlevel, comment.instanceid, comment.component, comment.itemid, + comment.area); + }); + + comments = this.utils.uniqueArray(comments, 'syncId'); + + // Sync all courses. + const promises = comments.map((comment) => { + const promise = force ? this.syncComments(comment.contextlevel, comment.instanceid, comment.component, + comment.itemid, comment.area, siteId) : this.syncCommentsIfNeeded(comment.contextlevel, comment.instanceid, + comment.component, comment.itemid, comment.area, siteId); + + return promise.then((warnings) => { + if (typeof warnings != 'undefined') { + // Sync successful, send event. + this.eventsProvider.trigger(CoreCommentsSyncProvider.AUTO_SYNCED, { + contextLevel: comment.contextlevel, + instanceId: comment.instanceid, + componentName: comment.component, + itemId: comment.itemid, + area: comment.area, + warnings: warnings + }, siteId); + } + }); + }); + + return Promise.all(promises); + }); + } + + /** + * Sync course comments only if a certain time has passed since the last time. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the comments are synced or if they don't need to be synced. + */ + private syncCommentsIfNeeded(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncComments(contextLevel, instanceId, component, itemId, area, siteId); + } + }); + } + + /** + * Synchronize comments in a particular area. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncComments(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for comments, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + this.logger.debug('Try to sync comments ' + syncId); + + const warnings = []; + + // Get offline comments to be sent. + const syncPromise = this.commentsOffline.getComments(contextLevel, instanceId, component, itemId, area, siteId) + .then((comments) => { + if (!comments.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + const errors = [], + promises = [], + deleteCommentIds = []; + + comments.forEach((comment) => { + if (comment.commentid) { + deleteCommentIds.push(comment.commentid); + } else { + promises.push(this.commentsProvider.addCommentOnline(comment.content, contextLevel, instanceId, component, + itemId, area, siteId).then((response) => { + return this.commentsOffline.removeComment(contextLevel, instanceId, component, itemId, area, siteId); + })); + } + }); + + if (deleteCommentIds.length > 0) { + promises.push(this.commentsProvider.deleteCommentsOnline(deleteCommentIds, contextLevel, instanceId, component, + itemId, area, siteId).then((response) => { + return this.commentsOffline.removeDeletedComments(contextLevel, instanceId, component, itemId, area, + siteId); + })); + } + + // Send the comments. + return Promise.all(promises).then(() => { + // Fetch the comments from server to be sure they're up to date. + return this.commentsProvider.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId) + .then(() => { + return this.commentsProvider.getComments(contextLevel, instanceId, component, itemId, area, 0, siteId); + }).catch(() => { + // Ignore errors. + }); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, this means the user cannot send comments. + errors.push(error.message); + } else { + // Not a WebService error, reject the synchronization to try again. + return Promise.reject(error); + } + }).then(() => { + if (errors && errors.length) { + errors.forEach((error) => { + warnings.push(this.translate.instant('core.comments.warningcommentsnotsent', { + error: error + })); + }); + } + }); + }).then(() => { + // All done, return the warnings. + return warnings; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Get the ID of a comments sync. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @return {string} Sync ID. + */ + protected getSyncId(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = ''): string { + return contextLevel + '#' + instanceId + '#' + component + '#' + itemId + '#' + area; + } +} diff --git a/src/core/compile/components/compile-html/compile-html.ts b/src/core/compile/components/compile-html/compile-html.ts index a263241d9e9..82c52cd8ebb 100644 --- a/src/core/compile/components/compile-html/compile-html.ts +++ b/src/core/compile/components/compile-html/compile-html.ts @@ -60,6 +60,7 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { protected element; protected differ: any; // To detect changes in the jsData input. protected creatingComponent = false; + protected pendingCalls = {}; constructor(protected compileProvider: CoreCompileProvider, protected cdr: ChangeDetectorRef, element: ElementRef, @Optional() protected navCtrl: NavController, differs: KeyValueDiffers, protected domUtils: CoreDomUtilsProvider, @@ -165,6 +166,22 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { if (compileInstance.javascript) { compileInstance.compileProvider.executeJavascript(this, compileInstance.javascript); } + + // Call the pending functions. + for (const name in compileInstance.pendingCalls) { + const pendingCall = compileInstance.pendingCalls[name]; + + if (typeof this[name] == 'function') { + // Call the function. + Promise.resolve(this[name].apply(this, pendingCall.params)).then(pendingCall.defer.resolve) + .catch(pendingCall.defer.reject); + } else { + // Function not defined, resolve the promise. + pendingCall.defer.resolve(); + } + } + + compileInstance.pendingCalls = {}; } /** @@ -200,4 +217,39 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { } } } + + /** + * Call a certain function on the component instance. + * + * @param {string} name Name of the function to call. + * @param {any[]} params List of params to send to the function. + * @param {boolean} [callWhenCreated=true] If this param is true and the component hasn't been created yet, call the function + * once the component has been created. + * @return {any} Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params?: any[], callWhenCreated: boolean = true): any { + if (this.componentInstance) { + if (typeof this.componentInstance[name] == 'function') { + return this.componentInstance[name].apply(this.componentInstance, params); + } + } else if (callWhenCreated) { + // Call it when the component is created. + + if (this.pendingCalls[name]) { + // Call already pending, just update the params (allow only 1 call per function until it's initialized). + this.pendingCalls[name].params = params; + + return this.pendingCalls[name].defer.promise; + } + + const defer = this.utils.promiseDefer(); + + this.pendingCalls[name] = { + params: params, + defer: defer + }; + + return defer.promise; + } + } } diff --git a/src/core/constants.ts b/src/core/constants.ts index ea038eecefd..a6f31c3a5a0 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -35,9 +35,12 @@ export class CoreConstants { static SETTINGS_DEBUG_DISPLAY = 'CoreSettingsDebugDisplay'; static SETTINGS_REPORT_IN_BACKGROUND = 'CoreSettingsReportInBackground'; // @deprecated since 3.5.0 static SETTINGS_SEND_ON_ENTER = 'CoreSettingsSendOnEnter'; + static SETTINGS_FONT_SIZE = 'CoreSettingsFontSize'; + static SETTINGS_ANALYTICS_ENABLED = 'CoreSettingsAnalyticsEnabled'; // WS constants. - static WS_TIMEOUT = 30000; + static WS_TIMEOUT = 30000; // Timeout when not in WiFi. + static WS_TIMEOUT_WIFI = 30000; // Timeout when in WiFi. static WS_PREFIX = 'local_mobile_'; // Login constants. diff --git a/src/core/contentlinks/classes/module-grade-handler.ts b/src/core/contentlinks/classes/module-grade-handler.ts index a97b30a2a71..c1a5124e4fd 100644 --- a/src/core/contentlinks/classes/module-grade-handler.ts +++ b/src/core/contentlinks/classes/module-grade-handler.ts @@ -77,7 +77,7 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB if (!params.userid || params.userid == site.getUserId()) { // No user specified or current user. Navigate to module. this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, - this.useModNameToGetModule ? this.modName : undefined); + this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl); } else if (this.canReview) { // Use the goToReview function. this.goToReview(url, params, courseId, siteId, navCtrl); diff --git a/src/core/contentlinks/classes/module-index-handler.ts b/src/core/contentlinks/classes/module-index-handler.ts index fbb65afba45..67159b01258 100644 --- a/src/core/contentlinks/classes/module-index-handler.ts +++ b/src/core/contentlinks/classes/module-index-handler.ts @@ -60,7 +60,7 @@ export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerB return [{ action: (siteId, navCtrl?): void => { this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, - this.useModNameToGetModule ? this.modName : undefined); + this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl); } }]; } diff --git a/src/core/contentlinks/classes/module-list-handler.ts b/src/core/contentlinks/classes/module-list-handler.ts index 5fa6c41a763..37e44e7d7a4 100644 --- a/src/core/contentlinks/classes/module-list-handler.ts +++ b/src/core/contentlinks/classes/module-list-handler.ts @@ -63,7 +63,6 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa title: this.title || this.translate.instant('addon.mod_' + this.modName + '.modulenameplural') }; - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'CoreCourseListModTypePage', stateParams, siteId); } }]; diff --git a/src/core/contentlinks/providers/helper.ts b/src/core/contentlinks/providers/helper.ts index bfd8b47b298..6e9f77e913a 100644 --- a/src/core/contentlinks/providers/helper.ts +++ b/src/core/contentlinks/providers/helper.ts @@ -30,6 +30,7 @@ import { CoreConstants } from '@core/constants'; import { CoreConfigConstants } from '../../../configconstants'; import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; import { CoreSite } from '@classes/site'; +import { CoreMainMenuProvider } from '@core/mainmenu/providers/mainmenu'; /** * Service that provides some features regarding content links. @@ -42,7 +43,8 @@ export class CoreContentLinksHelperProvider { private contentLinksDelegate: CoreContentLinksDelegate, private appProvider: CoreAppProvider, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private translate: TranslateService, private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, - private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone, private utils: CoreUtilsProvider) { + private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone, private utils: CoreUtilsProvider, + private mainMenuProvider: CoreMainMenuProvider) { this.logger = logger.getInstance('CoreContentLinksHelperProvider'); } @@ -103,9 +105,10 @@ export class CoreContentLinksHelperProvider { * @param {string} pageName Name of the page to go. * @param {any} [pageParams] Params to send to the page. * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [checkMenu] If true, check if the root page of a main menu tab. Only the page name will be checked. * @return {Promise} Promise resolved when done. */ - goInSite(navCtrl: NavController, pageName: string, pageParams: any, siteId?: string): Promise { + goInSite(navCtrl: NavController, pageName: string, pageParams: any, siteId?: string, checkMenu?: boolean): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const deferred = this.utils.promiseDefer(); @@ -113,7 +116,23 @@ export class CoreContentLinksHelperProvider { // Execute the code in the Angular zone, so change detection doesn't stop working. this.zone.run(() => { if (navCtrl && siteId == this.sitesProvider.getCurrentSiteId()) { - navCtrl.push(pageName, pageParams).then(deferred.resolve, deferred.reject); + if (checkMenu) { + // Check if the page is in the main menu. + this.mainMenuProvider.isCurrentMainMenuHandler(pageName, pageParams).catch(() => { + return false; // Shouldn't happen. + }).then((isInMenu) => { + if (isInMenu) { + // Just select the tab. + this.loginHelper.loadPageInMainMenu(pageName, pageParams); + + deferred.resolve(); + } else { + navCtrl.push(pageName, pageParams).then(deferred.resolve, deferred.reject); + } + }); + } else { + navCtrl.push(pageName, pageParams).then(deferred.resolve, deferred.reject); + } } else { this.loginHelper.redirect(pageName, pageParams, siteId).then(deferred.resolve, deferred.reject); } diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 925f9109bee..752a140e2aa 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -63,10 +63,10 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR const zone = injector.get(NgZone); // Refresh online status when changes. - this.onlineObserver = network.onchange().subscribe((online) => { + this.onlineObserver = network.onchange().subscribe(() => { // Execute the callback in the Angular zone, so change detection doesn't stop working. zone.run(() => { - this.isOnline = online; + this.isOnline = this.appProvider.isOnline(); }); }); } diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index b2a153add07..e357d088857 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -245,7 +245,6 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, * @param {any} event Event. */ gotoBlog(event: any): void { - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id }); } diff --git a/src/core/course/components/components.module.ts b/src/core/course/components/components.module.ts index a56f920d859..1f09dcd6f3b 100644 --- a/src/core/course/components/components.module.ts +++ b/src/core/course/components/components.module.ts @@ -18,10 +18,12 @@ import { IonicModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreBlockComponentsModule } from '@core/block/components/components.module'; import { CoreCourseFormatComponent } from './format/format'; import { CoreCourseModuleComponent } from './module/module'; import { CoreCourseModuleCompletionComponent } from './module-completion/module-completion'; import { CoreCourseModuleDescriptionComponent } from './module-description/module-description'; +import { CoreCourseTagAreaComponent } from './tag-area/tag-area'; import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsupported-module'; @NgModule({ @@ -30,9 +32,11 @@ import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsup CoreCourseModuleComponent, CoreCourseModuleCompletionComponent, CoreCourseModuleDescriptionComponent, + CoreCourseTagAreaComponent, CoreCourseUnsupportedModuleComponent ], imports: [ + CoreBlockComponentsModule, CommonModule, IonicModule, TranslateModule.forChild(), @@ -46,10 +50,12 @@ import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsup CoreCourseModuleComponent, CoreCourseModuleCompletionComponent, CoreCourseModuleDescriptionComponent, + CoreCourseTagAreaComponent, CoreCourseUnsupportedModuleComponent ], entryComponents: [ - CoreCourseUnsupportedModuleComponent + CoreCourseUnsupportedModuleComponent, + CoreCourseTagAreaComponent ] }) export class CoreCourseComponentsModule {} diff --git a/src/core/course/components/format/core-course-format.html b/src/core/course/components/format/core-course-format.html index 9ba99a96ce8..c7c3bf6ed96 100644 --- a/src/core/course/components/format/core-course-format.html +++ b/src/core/course/components/format/core-course-format.html @@ -5,67 +5,69 @@ - - - - - -
- - - -
-
- - - - -
- + + + + + + +
+ + +
- - - - -
+
- -
- - - + + + +
+ +
+ + + +
-
- -
- - - - + +
+ + + + +
+ + +
+ + + + + - - + - -
- - - - - + +
- + + + + + + +
@@ -82,7 +84,8 @@ - + diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts index 1c87d2aed72..8455d448774 100644 --- a/src/core/course/components/format/format.ts +++ b/src/core/course/components/format/format.ts @@ -13,7 +13,7 @@ // limitations under the License. import { - Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Output, EventEmitter, ViewChildren, QueryList, Injector + Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Output, EventEmitter, ViewChildren, QueryList, Injector, ViewChild } from '@angular/core'; import { Content, ModalController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; @@ -24,6 +24,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreCourseFormatDelegate } from '@core/course/providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreBlockCourseBlocksComponent } from '@core/block/components/course-blocks/course-blocks'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; /** @@ -52,6 +53,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { @Output() completionChanged?: EventEmitter; // Will emit an event when any module completion changes. @ViewChildren(CoreDynamicComponent) dynamicComponents: QueryList; + @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent: CoreBlockCourseBlocksComponent; // All the possible component classes. courseFormatComponent: any; @@ -76,12 +78,14 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { loaded: boolean; protected sectionStatusObserver; + protected selectTabObserver; protected lastCourseFormat: string; constructor(private cfDelegate: CoreCourseFormatDelegate, translate: TranslateService, private injector: Injector, private courseHelper: CoreCourseHelperProvider, private domUtils: CoreDomUtilsProvider, eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, private content: Content, - prefetchDelegate: CoreCourseModulePrefetchDelegate, private modalCtrl: ModalController) { + prefetchDelegate: CoreCourseModulePrefetchDelegate, private modalCtrl: ModalController, + private courseProvider: CoreCourseProvider) { this.selectOptions.title = translate.instant('core.course.sections'); this.completionChanged = new EventEmitter(); @@ -124,6 +128,28 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { }); } }, this.sitesProvider.getCurrentSiteId()); + + // Listen for select course tab events to select the right section if needed. + this.selectTabObserver = eventsProvider.on(CoreEventsProvider.SELECT_COURSE_TAB, (data) => { + + if (!data.name) { + let section; + + if (typeof data.sectionId != 'undefined' && data.sectionId != null && this.sections) { + section = this.sections.find((section) => { + return section.id == data.sectionId; + }); + } else if (typeof data.sectionNumber != 'undefined' && data.sectionNumber != null && this.sections) { + section = this.sections.find((section) => { + return section.section == data.sectionNumber; + }); + } + + if (section) { + this.sectionChanged(section); + } + } + }); } /** @@ -303,6 +329,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.canLoadMore = false; this.showSectionId = 0; this.showMoreActivities(); + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id, false, false); } if (this.moduleId && typeof previousValue == 'undefined') { @@ -312,6 +339,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { } else { this.domUtils.scrollToTop(this.content, 0); } + + if (!previousValue || previousValue.id != newSection.id) { + // First load or section changed, add log in Moodle. + this.courseProvider.logView(this.course.id, newSection.section, undefined, this.course.fullname).catch(() => { + // Ignore errors. + }); + } } /** @@ -389,6 +423,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { promises.push(Promise.resolve(component.callComponentFunction('doRefresh', [refresher, done, afterCompletionChange]))); }); + promises.push(this.courseBlocksComponent.invalidateBlocks().finally(() => { + return this.courseBlocksComponent.loadContent(); + })); + return Promise.all(promises); } @@ -437,9 +475,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * Component destroyed. */ ngOnDestroy(): void { - if (this.sectionStatusObserver) { - this.sectionStatusObserver.off(); - } + this.sectionStatusObserver && this.sectionStatusObserver.off(); + this.selectTabObserver && this.selectTabObserver.off(); } /** @@ -449,6 +486,14 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.dynamicComponents.forEach((component) => { component.callComponentFunction('ionViewDidEnter'); }); + if (this.downloadEnabled) { + // The download status of a section might have been changed from within a module page. + if (this.selectedSection && this.selectedSection.id !== CoreCourseProvider.ALL_SECTIONS_ID) { + this.courseHelper.calculateSectionStatus(this.selectedSection, this.course.id, false, false); + } else { + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id, false, false); + } + } } /** @@ -494,4 +539,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { // Emit a new event for other components. this.completionChanged.emit(completionData); } + + /** + * Recalculate the download status of each section, in response to a module being downloaded. + * + * @param {any} eventData + */ + onModuleStatusChange(eventData: any): void { + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id, false, false); + } } diff --git a/src/core/course/components/module/core-course-module.html b/src/core/course/components/module/core-course-module.html index 55686efd032..eae6da52c4f 100644 --- a/src/core/course/components/module/core-course-module.html +++ b/src/core/course/components/module/core-course-module.html @@ -1,4 +1,4 @@ - +
@@ -32,4 +32,4 @@ {{ 'core.course.manualcompletionnotsynced' | translate }}
-
\ No newline at end of file + \ No newline at end of file diff --git a/src/core/course/components/module/module.scss b/src/core/course/components/module/module.scss index 410482e7639..c08daa491ca 100644 --- a/src/core/course/components/module/module.scss +++ b/src/core/course/components/module/module.scss @@ -2,7 +2,7 @@ ion-app.app-root core-course-module { background: white; display: block; - a.core-course-module-handler { + .item.core-course-module-handler { align-items: flex-start; min-height: 52px; @@ -80,7 +80,7 @@ ion-app.app-root.md core-course-module { } } - a.core-course-module-handler .core-module-icon { + .item.core-course-module-handler .core-module-icon { margin-top: $label-md-margin-top; margin-bottom: $label-md-margin-bottom; width: 24px; @@ -110,7 +110,7 @@ ion-app.app-root.ios core-course-module { } } - a.core-course-module-handler .core-module-icon { + .item.core-course-module-handler .core-module-icon { margin-top: $label-ios-margin-top; margin-bottom: $label-ios-margin-bottom; width: 24px; @@ -137,7 +137,7 @@ ion-app.app-root.wp core-course-module { } } - a.core-course-module-handler .core-module-icon { + .item.core-course-module-handler .core-module-icon { margin-top: $item-wp-padding-top; margin-bottom: $item-wp-padding-bottom; width: 24px; @@ -154,6 +154,6 @@ ion-app.app-root.wp core-course-module { } } -ion-app.app-root a.core-course-module-handler.item [item-start] + .item-inner { +ion-app.app-root .core-course-module-handler.item [item-start] + .item-inner { @include margin-horizontal(4px, null); } \ No newline at end of file diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts index b4d8cba41e8..3bdb1a89830 100644 --- a/src/core/course/components/module/module.ts +++ b/src/core/course/components/module/module.ts @@ -50,6 +50,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { } } @Output() completionChanged?: EventEmitter; // Will emit an event when the module completion changes. + @Output() statusChanged?: EventEmitter; // Will emit an event when the download status changes. downloadStatus: string; canCheckUpdates: boolean; @@ -66,6 +67,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider, protected courseProvider: CoreCourseProvider) { this.completionChanged = new EventEmitter(); + this.statusChanged = new EventEmitter(); } /** @@ -148,6 +150,13 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { // Get download size to ask for confirm if it's high. this.prefetchHandler.getDownloadSize(this.module, this.courseId, true).then((size) => { return this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh); + }).then(() => { + const eventData = { + sectionId: this.section.id, + moduleId: this.module.id, + courseId: this.courseId + }; + this.statusChanged.emit(eventData); }).catch((error) => { // Error, hide spinner. this.spinner = false; diff --git a/src/core/course/components/tag-area/core-course-tag-area.html b/src/core/course/components/tag-area/core-course-tag-area.html new file mode 100644 index 00000000000..b372fdf0915 --- /dev/null +++ b/src/core/course/components/tag-area/core-course-tag-area.html @@ -0,0 +1,5 @@ + + +

{{ item.courseName }}

+

{{ 'core.category' | translate }}: {{ item.categoryName }}

+
diff --git a/src/core/course/components/tag-area/tag-area.ts b/src/core/course/components/tag-area/tag-area.ts new file mode 100644 index 00000000000..07d34c21c19 --- /dev/null +++ b/src/core/course/components/tag-area/tag-area.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Optional } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Component that renders the course tag area. + */ +@Component({ + selector: 'core-course-tag-area', + templateUrl: 'core-course-tag-area.html' +}) +export class CoreCourseTagAreaComponent { + @Input() items: any[]; // Area items to render. + + constructor(private navCtrl: NavController, @Optional() private splitviewCtrl: CoreSplitViewComponent, + private courseHelper: CoreCourseHelperProvider) {} + + /** + * Open a course. + * + * @param {number} courseId The course to open. + */ + openCourse(courseId: number): void { + // If this component is inside a split view, use the master nav to open it. + const navCtrl = this.splitviewCtrl ? this.splitviewCtrl.getMasterNav() : this.navCtrl; + this.courseHelper.getAndOpenCourse(navCtrl, courseId); + } +} diff --git a/src/core/course/course.module.ts b/src/core/course/course.module.ts index 5293eda6333..52a686a40b8 100644 --- a/src/core/course/course.module.ts +++ b/src/core/course/course.module.ts @@ -33,6 +33,9 @@ import { CoreCourseFormatWeeksModule } from './formats/weeks/weeks.module'; import { CoreCourseSyncProvider } from './providers/sync'; import { CoreCourseSyncCronHandler } from './providers/sync-cron-handler'; import { CoreCourseLogCronHandler } from './providers/log-cron-handler'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; +import { CoreCourseTagAreaHandler } from './providers/course-tag-area-handler'; +import { CoreCourseModulesTagAreaHandler } from './providers/modules-tag-area-handler'; // List of providers (without handlers). export const CORE_COURSE_PROVIDERS: any[] = [ @@ -68,15 +71,20 @@ export const CORE_COURSE_PROVIDERS: any[] = [ CoreCourseFormatDefaultHandler, CoreCourseModuleDefaultHandler, CoreCourseSyncCronHandler, - CoreCourseLogCronHandler + CoreCourseLogCronHandler, + CoreCourseTagAreaHandler, + CoreCourseModulesTagAreaHandler ], exports: [] }) export class CoreCourseModule { constructor(cronDelegate: CoreCronDelegate, syncHandler: CoreCourseSyncCronHandler, logHandler: CoreCourseLogCronHandler, - platform: Platform, eventsProvider: CoreEventsProvider) { + platform: Platform, eventsProvider: CoreEventsProvider, tagAreaDelegate: CoreTagAreaDelegate, + courseTagAreaHandler: CoreCourseTagAreaHandler, modulesTagAreaHandler: CoreCourseModulesTagAreaHandler) { cronDelegate.register(syncHandler); cronDelegate.register(logHandler); + tagAreaDelegate.registerHandler(courseTagAreaHandler); + tagAreaDelegate.registerHandler(modulesTagAreaHandler); platform.resume.subscribe(() => { // Log the app is open to keep user in online status. @@ -88,7 +96,9 @@ export class CoreCourseModule { eventsProvider.on(CoreEventsProvider.LOGIN, () => { // Log the app is open to keep user in online status. setTimeout(() => { - cronDelegate.forceCronHandlerExecution(logHandler.name); + cronDelegate.forceCronHandlerExecution(logHandler.name).catch((e) => { + // Ignore errors here, since probably login is not complete: it happens on token invalid. + }); }, 1000); }); } diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index c2d67c3f1e7..85f3ab89ec0 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -20,6 +20,8 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreCourseProvider } from '../../providers/course'; import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; @@ -28,8 +30,6 @@ import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay, CoreCourseOptionsMenuHandlerToDisplay } from '../../providers/options-delegate'; import { CoreCourseSyncProvider } from '../../providers/sync'; import { CoreCourseFormatComponent } from '../../components/format/format'; -import { CoreCoursesProvider } from '@core/courses/providers/courses'; -import { CoreTabsComponent } from '@components/tabs/tabs'; /** * Page that displays the list of courses the user is enrolled in. @@ -67,6 +67,7 @@ export class CoreCourseSectionPage implements OnDestroy { protected modParams: any; protected completionObserver; protected courseStatusObserver; + protected selectTabObserver; protected syncObserver; protected firstTabName: string; protected isDestroyed = false; @@ -120,6 +121,26 @@ export class CoreCourseSectionPage implements OnDestroy { } }, sitesProvider.getCurrentSiteId()); } + + this.selectTabObserver = eventsProvider.on(CoreEventsProvider.SELECT_COURSE_TAB, (data) => { + + if (!data.name) { + // If needed, set sectionId and sectionNumber. They'll only be used if the content tabs hasn't been loaded yet. + this.sectionId = data.sectionId || this.sectionId; + this.sectionNumber = data.sectionNumber || this.sectionNumber; + + // Select course contents. + this.tabsComponent && this.tabsComponent.selectTab(0); + } else if (this.courseHandlers) { + const index = this.courseHandlers.findIndex((handler) => { + return handler.name == data.name; + }); + + if (index >= 0) { + this.tabsComponent && this.tabsComponent.selectTab(index + 1); + } + } + }); } /** @@ -213,11 +234,6 @@ export class CoreCourseSectionPage implements OnDestroy { }).then((sections) => { let promise; - // Add log in Moodle. - this.courseProvider.logView(this.course.id, this.sectionNumber, undefined, this.course.fullname).catch(() => { - // Ignore errors. - }); - // Get the completion status. if (this.course.enablecompletion === false) { // Completion not enabled. @@ -426,15 +442,7 @@ export class CoreCourseSectionPage implements OnDestroy { */ prefetchCourse(): void { this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course, this.sections, - this.courseHandlers, this.courseMenuHandlers) - .then(() => { - if (this.downloadEnabled) { - // Recalculate the status. - this.courseHelper.calculateSectionsStatus(this.sections, this.course.id).catch(() => { - // Ignore errors (shouldn't happen). - }); - } - }).catch((error) => { + this.courseHandlers, this.courseMenuHandlers).catch((error) => { if (!this.isDestroyed) { this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); } @@ -483,9 +491,8 @@ export class CoreCourseSectionPage implements OnDestroy { */ ngOnDestroy(): void { this.isDestroyed = true; - if (this.completionObserver) { - this.completionObserver.off(); - } + this.completionObserver && this.completionObserver.off(); + this.selectTabObserver && this.selectTabObserver.off(); } /** diff --git a/src/core/course/providers/course-tag-area-handler.ts b/src/core/course/providers/course-tag-area-handler.ts new file mode 100644 index 00000000000..066754de601 --- /dev/null +++ b/src/core/course/providers/course-tag-area-handler.ts @@ -0,0 +1,74 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreCourseTagAreaComponent } from '../components/tag-area/tag-area'; + +/** + * Handler to support tags. + */ +@Injectable() +export class CoreCourseTagAreaHandler implements CoreTagAreaHandler { + name = 'CoreCourseTagAreaHandler'; + type = 'core/course'; + + constructor(private domUtils: CoreDomUtilsProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + const items = []; + const element = this.domUtils.convertToElement(content); + + Array.from(element.querySelectorAll('div.coursebox')).forEach((coursebox) => { + const courseId = parseInt(coursebox.getAttribute('data-courseid'), 10); + const courseLink = coursebox.querySelector('.coursename > a'); + const categoryLink = coursebox.querySelector('.coursecat > a'); + + if (courseId > 0 && courseLink) { + items.push({ + courseId, + courseName: courseLink.innerHTML, + categoryName: categoryLink ? categoryLink.innerHTML : null + }); + } + }); + + return items; + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreCourseTagAreaComponent; + } +} diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index bb4a55c5a89..d1579357467 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -114,10 +114,11 @@ export class CoreCourseProvider { * Check if the get course blocks WS is available in current site. * * @return {boolean} Whether it's available. - * @since 3.3 + * @since 3.7 */ canGetCourseBlocks(): boolean { - return this.sitesProvider.wsAvailableInCurrentSite('core_block_get_course_blocks'); + return this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.7') && + this.sitesProvider.wsAvailableInCurrentSite('core_block_get_course_blocks'); } /** @@ -164,6 +165,23 @@ export class CoreCourseProvider { }); } + /** + * Check if the current view in a NavController is a certain course initial page. + * + * @param {NavController} navCtrl NavController. + * @param {number} courseId Course ID. + * @return {boolean} Whether the current view is a certain course. + */ + currentViewIsCourse(navCtrl: NavController, courseId: number): boolean { + if (navCtrl) { + const view = navCtrl.getActive(); + + return view && view.id == 'CoreCourseSectionPage' && view.data && view.data.course && view.data.course.id == courseId; + } + + return false; + } + /** * Get completion status of all the activities in a course for a certain user. * @@ -250,12 +268,13 @@ export class CoreCourseProvider { * @param {number} courseId Course ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the list of blocks. - * @since 3.3 + * @since 3.7 */ getCourseBlocks(courseId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { - courseid: courseId + courseid: courseId, + returncontents: 1 }, preSets: CoreSiteWSPreSets = { cacheKey: this.getCourseBlocksCacheKey(courseId), @@ -962,7 +981,12 @@ export class CoreCourseProvider { } }).catch(() => { // The site plugin failed to load. The user needs to restart the app to try loading it again. - this.domUtils.showErrorModal('core.courses.errorloadplugins', true); + const message = this.translate.instant('core.courses.errorloadplugins'); + const reload = this.translate.instant('core.courses.reload'); + const ignore = this.translate.instant('core.courses.ignore'); + this.domUtils.showConfirm(message, '', reload, ignore).then(() => { + window.location.reload(); + }); }); } else { // No custom format plugin. We don't need to wait for anything. @@ -973,6 +997,19 @@ export class CoreCourseProvider { }); } + /** + * Select a certain tab in the course. Please use currentViewIsCourse() first to verify user is viewing the course. + * + * @param {string} [name] Name of the tab. If not provided, course contents. + * @param {any} [params] Other params. + */ + selectCourseTab(name?: string, params?: any): void { + params = params || {}; + params.name = name || ''; + + this.eventsProvider.trigger(CoreEventsProvider.SELECT_COURSE_TAB, params); + } + /** * Change the course status, setting it to the previous status. * diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 0a7b95247f0..73d30973c09 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -36,6 +36,7 @@ import { CoreCourseModulePrefetchDelegate } from './module-prefetch-delegate'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreConstants } from '@core/constants'; import { CoreSite } from '@classes/site'; +import { CoreLoggerProvider } from '@providers/logger'; import * as moment from 'moment'; /** @@ -115,16 +116,21 @@ export type CoreCourseCoursesProgress = { export class CoreCourseHelperProvider { protected courseDwnPromises: { [s: string]: { [id: number]: Promise } } = {}; + protected logger; constructor(private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, - private moduleDelegate: CoreCourseModuleDelegate, private prefetchDelegate: CoreCourseModulePrefetchDelegate, - private filepoolProvider: CoreFilepoolProvider, private sitesProvider: CoreSitesProvider, - private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, - private utils: CoreUtilsProvider, private translate: TranslateService, private loginHelper: CoreLoginHelperProvider, - private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider, - private eventsProvider: CoreEventsProvider, private fileHelper: CoreFileHelperProvider, - private appProvider: CoreAppProvider, private fileProvider: CoreFileProvider, private injector: Injector, - private coursesProvider: CoreCoursesProvider, private courseOffline: CoreCourseOfflineProvider) { } + private moduleDelegate: CoreCourseModuleDelegate, private prefetchDelegate: CoreCourseModulePrefetchDelegate, + private filepoolProvider: CoreFilepoolProvider, private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, private translate: TranslateService, private loginHelper: CoreLoginHelperProvider, + private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider, + private eventsProvider: CoreEventsProvider, private fileHelper: CoreFileHelperProvider, + private appProvider: CoreAppProvider, private fileProvider: CoreFileProvider, private injector: Injector, + private coursesProvider: CoreCoursesProvider, private courseOffline: CoreCourseOfflineProvider, + loggerProvider: CoreLoggerProvider) { + + this.logger = loggerProvider.getInstance('CoreCourseHelperProvider'); + } /** * This function treats every module on the sections provided to load the handler data, treat completion @@ -181,16 +187,19 @@ export class CoreCourseHelperProvider { * @param {any} section Section to calculate its status. It can't be "All sections". * @param {number} courseId Course ID the section belongs to. * @param {boolean} [refresh] True if it shouldn't use module status cache (slower). + * @param {boolean} [checkUpdates=true] Whether to use the WS to check updates. Defaults to true. * @return {Promise} Promise resolved when the status is calculated. */ - calculateSectionStatus(section: any, courseId: number, refresh?: boolean): Promise { + calculateSectionStatus(section: any, courseId: number, refresh?: boolean, checkUpdates: boolean = true): Promise { if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { return Promise.reject(null); } // Get the status of this section. - return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id, refresh, true).then((result) => { + return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id, refresh, true, checkUpdates) + .then((result) => { + // Check if it's being downloaded. const downloadId = this.getSectionDownloadId(section); if (this.prefetchDelegate.isBeingDownloaded(downloadId)) { @@ -222,9 +231,10 @@ export class CoreCourseHelperProvider { * @param {any[]} sections Sections to calculate their status. * @param {number} courseId Course ID the sections belong to. * @param {boolean} [refresh] True if it shouldn't use module status cache (slower). + * @param {boolean} [checkUpdates=true] Whether to use the WS to check updates. Defaults to true. * @return {Promise} Promise resolved when the states are calculated. */ - calculateSectionsStatus(sections: any[], courseId: number, refresh?: boolean): Promise { + calculateSectionsStatus(sections: any[], courseId: number, refresh?: boolean, checkUpdates: boolean = true): Promise { const promises = []; let allSectionsSection, allSectionsStatus; @@ -236,7 +246,7 @@ export class CoreCourseHelperProvider { section.isCalculating = true; } else { section.isCalculating = true; - promises.push(this.calculateSectionStatus(section, courseId, refresh).then((result) => { + promises.push(this.calculateSectionStatus(section, courseId, refresh, checkUpdates).then((result) => { // Calculate "All sections" status. allSectionsStatus = this.filepoolProvider.determinePackagesStatus(allSectionsStatus, result.status); }).finally(() => { @@ -1109,9 +1119,12 @@ export class CoreCourseHelperProvider { * @param {string} [modName] If set, the app will retrieve all modules of this type with a single WS call. This reduces the * number of WS calls, but it isn't recommended for modules that can return a lot of contents. * @param {any} [modParams] Params to pass to the module + * @param {NavController} [navCtrl] NavController for adding new pages to the current history. Optional for legacy support, but + * generates a warning if omitted. * @return {Promise} Promise resolved when done. */ - navigateToModule(moduleId: number, siteId?: string, courseId?: number, sectionId?: number, modName?: string, modParams?: any) + navigateToModule(moduleId: number, siteId?: string, courseId?: number, sectionId?: number, modName?: string, modParams?: any, + navCtrl?: NavController) : Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); @@ -1157,6 +1170,16 @@ export class CoreCourseHelperProvider { module.handlerData = this.moduleDelegate.getModuleDataFor(module.modname, module, courseId, sectionId); + if (navCtrl && module.handlerData && module.handlerData.action) { + // If the link handler for this module passed through navCtrl, we can use the module's handler to navigate cleanly. + // Otherwise, we will redirect below. + modal.dismiss(); + + return module.handlerData.action(new Event('click'), navCtrl, module, courseId); + } + + this.logger.warn('navCtrl was not passed to navigateToModule by the link handler for ' + module.modname); + if (courseId == site.getSiteHomeId()) { // Check if site home is available. return this.siteHomeProvider.isAvailable().then(() => { @@ -1312,7 +1335,7 @@ export class CoreCourseHelperProvider { // Download only this section. return this.prefetchSingleSectionIfNeeded(section, courseId).finally(() => { // Calculate the status of the section that finished. - return this.calculateSectionStatus(section, courseId); + return this.calculateSectionStatus(section, courseId, false, false); }); } else { // Download all the sections except "All sections". @@ -1324,7 +1347,7 @@ export class CoreCourseHelperProvider { if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { promises.push(this.prefetchSingleSectionIfNeeded(section, courseId).finally(() => { // Calculate the status of the section that finished. - return this.calculateSectionStatus(section, courseId).then((result) => { + return this.calculateSectionStatus(section, courseId, false, false).then((result) => { // Calculate "All sections" status. allSectionsStatus = this.filepoolProvider.determinePackagesStatus(allSectionsStatus, result.status); }); diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index efb2564004f..267027d84cc 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -795,6 +795,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { * @param {number} [sectionId] ID of the section the modules belong to. * @param {boolean} [refresh] True if it should always check the DB (slower). * @param {boolean} [onlyToDisplay] True if the status will only be used to determine which button should be displayed. + * @param {boolean} [checkUpdates=true] Whether to use the WS to check updates. Defaults to true. * @return {Promise} Promise resolved with an object with the following properties: * - status (string) Status of the module. * - total (number) Number of modules. @@ -803,12 +804,15 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { * - CoreConstants.DOWNLOADING (any[]) Modules with state DOWNLOADING. * - CoreConstants.OUTDATED (any[]) Modules with state OUTDATED. */ - getModulesStatus(modules: any[], courseId: number, sectionId?: number, refresh?: boolean, onlyToDisplay?: boolean): any { + getModulesStatus(modules: any[], courseId: number, sectionId?: number, refresh?: boolean, onlyToDisplay?: boolean, + checkUpdates: boolean = true): any { + const promises = [], result: any = { total: 0 }; - let status = CoreConstants.NOT_DOWNLOADABLE; + let status = CoreConstants.NOT_DOWNLOADABLE, + promise; // Init result. result[CoreConstants.NOT_DOWNLOADED] = []; @@ -816,11 +820,17 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { result[CoreConstants.DOWNLOADING] = []; result[CoreConstants.OUTDATED] = []; - // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list. - return this.getCourseUpdatesByCourseId(courseId).catch(() => { - // Cannot get updates. - return false; - }).then((updates) => { + if (checkUpdates) { + // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list. + promise = this.getCourseUpdatesByCourseId(courseId).catch(() => { + // Cannot get updates. + return false; + }); + } else { + promise = Promise.resolve(false); + } + + return promise.then((updates) => { modules.forEach((module) => { // Check if the module has a prefetch handler. diff --git a/src/core/course/providers/modules-tag-area-handler.ts b/src/core/course/providers/modules-tag-area-handler.ts new file mode 100644 index 00000000000..4b7dcfc0a7d --- /dev/null +++ b/src/core/course/providers/modules-tag-area-handler.ts @@ -0,0 +1,57 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreTagHelperProvider } from '@core/tag/providers/helper'; +import { CoreTagFeedComponent } from '@core/tag/components/feed/feed'; + +/** + * Handler to support tags. + */ +@Injectable() +export class CoreCourseModulesTagAreaHandler implements CoreTagAreaHandler { + name = 'CoreCourseModulesTagAreaHandler'; + type = 'core/course_modules'; + + constructor(protected tagHelper: CoreTagHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + return this.tagHelper.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreTagFeedComponent; + } +} diff --git a/src/core/course/providers/options-delegate.ts b/src/core/course/providers/options-delegate.ts index cccfde28172..8f0ac612415 100644 --- a/src/core/course/providers/options-delegate.ts +++ b/src/core/course/providers/options-delegate.ts @@ -52,10 +52,10 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { * Returns the data needed to render the handler. * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData?(injector: Injector, courseId: number): CoreCourseOptionsHandlerData | Promise; + getDisplayData?(injector: Injector, course: any): CoreCourseOptionsHandlerData | Promise; /** * Should invalidate the data to determine if the handler is enabled for a certain course. @@ -84,10 +84,10 @@ export interface CoreCourseOptionsMenuHandler extends CoreCourseOptionsHandler { * Returns the data needed to render the handler. * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsMenuHandlerData|Promise} Data or promise resolved with data. */ - getMenuDisplayData(injector: Injector, courseId: number): + getMenuDisplayData(injector: Injector, course: any): CoreCourseOptionsMenuHandlerData | Promise; } @@ -552,16 +552,12 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { /** * Update handlers for each course. - * - * @param {string} [siteId] Site ID. - */ - updateData(siteId?: string): void { - if (this.sitesProvider.getCurrentSiteId() === siteId) { - // Update handlers for all courses. - for (const courseId in this.coursesHandlers) { - const handler = this.coursesHandlers[courseId]; - this.updateHandlersForCourse(parseInt(courseId, 10), handler.access, handler.navOptions, handler.admOptions); - } + */ + updateData(): void { + // Update handlers for all courses. + for (const courseId in this.coursesHandlers) { + const handler = this.coursesHandlers[courseId]; + this.updateHandlersForCourse(parseInt(courseId, 10), handler.access, handler.navOptions, handler.admOptions); } } diff --git a/src/core/courses/lang/en.json b/src/core/courses/lang/en.json index 9409f2ee071..ef69785f5d7 100644 --- a/src/core/courses/lang/en.json +++ b/src/core/courses/lang/en.json @@ -11,11 +11,12 @@ "enrolme": "Enrol me", "errorloadcategories": "An error occurred while loading categories.", "errorloadcourses": "An error occurred while loading courses.", - "errorloadplugins": "The plugins required by this course could not be loaded correctly. Please restart the app to try again.", + "errorloadplugins": "The plugins required by this course could not be loaded correctly. Please reload the app to try again.", "errorsearching": "An error occurred while searching.", "errorselfenrol": "An error occurred while self enrolling.", "filtermycourses": "Filter my courses", "frontpage": "Front page", + "ignore": "Ignore", "hidecourse": "Hide from view", "mycourses": "My courses", "nocourses": "No course information to show.", @@ -26,6 +27,7 @@ "password": "Enrolment key", "paymentrequired": "This course requires a payment for entry.", "paypalaccepted": "PayPal payments accepted", + "reload": "Reload", "removefromfavourites": "Unstar this course", "search": "Search", "searchcourses": "Search courses", @@ -34,4 +36,4 @@ "sendpaymentbutton": "Send payment via PayPal", "show": "Show this course", "totalcoursesearchresults": "Total courses: {{$a}}" -} \ No newline at end of file +} diff --git a/src/core/courses/pages/dashboard/dashboard.html b/src/core/courses/pages/dashboard/dashboard.html index f9eff8228fe..d94fcdd0a88 100644 --- a/src/core/courses/pages/dashboard/dashboard.html +++ b/src/core/courses/pages/dashboard/dashboard.html @@ -26,7 +26,7 @@ - + diff --git a/src/core/courses/providers/course-link-handler.ts b/src/core/courses/providers/course-link-handler.ts index 010dcdbb202..5ffe35cb68f 100644 --- a/src/core/courses/providers/course-link-handler.ts +++ b/src/core/courses/providers/course-link-handler.ts @@ -22,6 +22,8 @@ import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreCoursesProvider } from './courses'; +import { NavController } from 'ionic-angular'; +import { CoreLoggerProvider } from '@providers/logger'; /** * Handler to treat links to course view or enrol (except site home). @@ -32,12 +34,16 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { pattern = /((\/enrol\/index\.php)|(\/course\/enrol\.php)|(\/course\/view\.php)).*([\?\&]id=\d+)/; protected waitStart = 0; + protected logger; constructor(private sitesProvider: CoreSitesProvider, private coursesProvider: CoreCoursesProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private courseProvider: CoreCourseProvider, - private textUtils: CoreTextUtilsProvider, private courseHelper: CoreCourseHelperProvider) { + private textUtils: CoreTextUtilsProvider, private courseHelper: CoreCourseHelperProvider, + loggerProvider: CoreLoggerProvider) { super(); + + this.logger = loggerProvider.getInstance('CoreCoursesCourseLinkHandler'); } /** @@ -75,9 +81,17 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { action: (siteId, navCtrl?): void => { siteId = siteId || this.sitesProvider.getCurrentSiteId(); if (siteId == this.sitesProvider.getCurrentSiteId()) { - this.actionEnrol(courseId, url, pageParams).catch(() => { - // Ignore errors. - }); + // Check if we already are in the course index page. + if (this.courseProvider.currentViewIsCourse(navCtrl, courseId)) { + // Current view is this course, just select the contents tab. + this.courseProvider.selectCourseTab('', pageParams); + + return; + } else { + this.actionEnrol(courseId, url, pageParams, navCtrl).catch(() => { + // Ignore errors. + }); + } } else { // Don't pass the navCtrl to make the course the new history root (to avoid "loops" in history). this.courseHelper.getAndOpenCourse(undefined, courseId, pageParams, siteId); @@ -115,9 +129,11 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { * @param {number} courseId Course ID. * @param {string} url Treated URL. * @param {any} pageParams Params to send to the new page. + * @param {NavController} [navCtrl] NavController for adding new pages to the current history. Optional for legacy support, but + * generates a warning if omitted. * @return {Promise} Promise resolved when done. */ - protected actionEnrol(courseId: number, url: string, pageParams: any): Promise { + protected actionEnrol(courseId: number, url: string, pageParams: any, navCtrl?: NavController): Promise { const modal = this.domUtils.showModalLoading(), isEnrolUrl = !!url.match(/(\/enrol\/index\.php)|(\/course\/enrol\.php)/); let course; @@ -188,8 +204,12 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { }).then((course) => { modal.dismiss(); + if (typeof navCtrl === 'undefined') { + this.logger.warn('navCtrl was not passed to actionEnrol'); + } + // Now open the course. - this.courseHelper.openCourse(undefined, course, pageParams); + this.courseHelper.openCourse(navCtrl, course, pageParams); }); } diff --git a/src/core/courses/providers/courses-index-link-handler.ts b/src/core/courses/providers/courses-index-link-handler.ts index 834629eacb2..bbea7ec34fa 100644 --- a/src/core/courses/providers/courses-index-link-handler.ts +++ b/src/core/courses/providers/courses-index-link-handler.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreCoursesProvider } from './courses'; /** @@ -27,7 +27,7 @@ export class CoreCoursesIndexLinkHandler extends CoreContentLinksHandlerBase { featureName = 'CoreMainMenuDelegate_CoreCourses'; pattern = /\/course\/?(index\.php.*)?$/; - constructor(private coursesProvider: CoreCoursesProvider, private loginHelper: CoreLoginHelperProvider) { + constructor(private coursesProvider: CoreCoursesProvider, private linkHelper: CoreContentLinksHelperProvider) { super(); } @@ -56,8 +56,7 @@ export class CoreCoursesIndexLinkHandler extends CoreContentLinksHandlerBase { } } - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect(page, pageParams, siteId); + this.linkHelper.goInSite(navCtrl, page, pageParams, siteId); } }]; } diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index c50cc965674..c63780c5cb9 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSite } from '@classes/site'; @@ -24,13 +25,16 @@ import { CoreSite } from '@classes/site'; export class CoreCoursesProvider { static SEARCH_PER_PAGE = 20; static ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; - static EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; + static EVENT_MY_COURSES_CHANGED = 'courses_my_courses_changed'; // User course list changed while app is running. + static EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; // A course was hidden/favourite, or user enroled in a course. static EVENT_MY_COURSES_REFRESHED = 'courses_my_courses_refreshed'; static EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED = 'dashboard_download_enabled_changed'; + protected ROOT_CACHE_KEY = 'mmCourses:'; protected logger; + protected userCoursesIds: {[id: number]: boolean}; // Use an object to make it faster to search. - constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider) { this.logger = logger.getInstance('CoreCoursesProvider'); } @@ -743,7 +747,53 @@ export class CoreCoursesProvider { data.returnusercount = 0; } - return site.read('core_enrol_get_users_courses', data, preSets); + return site.read('core_enrol_get_users_courses', data, preSets).then((courses) => { + if (this.userCoursesIds) { + // Check if the list of courses has changed. + const added = [], + removed = [], + previousIds = Object.keys(this.userCoursesIds), + currentIds = {}; // Use an object to make it faster to search. + + courses.forEach((course) => { + currentIds[course.id] = true; + + if (!this.userCoursesIds[course.id]) { + // Course added. + added.push(course.id); + } + }); + + if (courses.length - added.length != previousIds.length) { + // A course was removed, check which one. + previousIds.forEach((id) => { + if (!currentIds[id]) { + // Course removed. + removed.push(Number(id)); + } + }); + } + + if (added.length || removed.length) { + // At least 1 course was added or removed, trigger the event. + this.eventsProvider.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, { + added: added, + removed: removed + }, site.getId()); + } + + this.userCoursesIds = currentIds; + } else { + this.userCoursesIds = {}; + + // Store the list of courses. + courses.forEach((course) => { + this.userCoursesIds[course.id] = true; + }); + } + + return courses; + }); }); } diff --git a/src/core/courses/providers/dashboard-link-handler.ts b/src/core/courses/providers/dashboard-link-handler.ts index 26f00164ef7..aaa0d7ab511 100644 --- a/src/core/courses/providers/dashboard-link-handler.ts +++ b/src/core/courses/providers/dashboard-link-handler.ts @@ -43,7 +43,7 @@ export class CoreCoursesDashboardLinkHandler extends CoreContentLinksHandlerBase CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). + // Use redirect to select the tab. this.loginHelper.redirect('CoreCoursesDashboardPage', undefined, siteId); } }]; diff --git a/src/core/courses/providers/dashboard.ts b/src/core/courses/providers/dashboard.ts index 24ac80e2c11..beb6204d06b 100644 --- a/src/core/courses/providers/dashboard.ts +++ b/src/core/courses/providers/dashboard.ts @@ -47,6 +47,7 @@ export class CoreCoursesDashboardProvider { getDashboardBlocks(userId?: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { + returncontents: 1 }, preSets = { cacheKey: this.getDashboardBlocksCacheKey(userId), diff --git a/src/core/courses/providers/helper.ts b/src/core/courses/providers/helper.ts index 6ff3ba5cfe4..abd9634fe5d 100644 --- a/src/core/courses/providers/helper.ts +++ b/src/core/courses/providers/helper.ts @@ -13,9 +13,12 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { PopoverController } from 'ionic-angular'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCoursesProvider } from './courses'; import { AddonCourseCompletionProvider } from '@addon/coursecompletion/providers/coursecompletion'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover'; /** * Helper to gather some common courses functions. @@ -23,8 +26,46 @@ import { AddonCourseCompletionProvider } from '@addon/coursecompletion/providers @Injectable() export class CoreCoursesHelperProvider { - constructor(private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider, - private courseCompletionProvider: AddonCourseCompletionProvider) { } + constructor(private coursesProvider: CoreCoursesProvider, + private utils: CoreUtilsProvider, + private courseCompletionProvider: AddonCourseCompletionProvider, + private translate: TranslateService, + private popoverCtrl: PopoverController) { } + + /** + * Get the courses to display the course picker popover. If a courseId is specified, it will also return its categoryId. + * + * @param {number} [courseId] Course ID to get the category. + * @return {Promise<{courses: any[], categoryId: number}>} Promise resolved with the list of courses and the category. + */ + getCoursesForPopover(courseId?: number): Promise<{courses: any[], categoryId: number}> { + return this.coursesProvider.getUserCourses(false).then((courses) => { + // Add "All courses". + courses.unshift({ + id: -1, + fullname: this.translate.instant('core.fulllistofcourses'), + category: -1 + }); + + let categoryId; + + if (courseId) { + // Search the course to get the category. + const course = courses.find((course) => { + return course.id == courseId; + }); + + if (course) { + categoryId = course.category; + } + } + + return { + courses: courses, + categoryId: categoryId + }; + }); + } /** * Given a course object returned by core_enrol_get_users_courses and another one returned by core_course_get_courses_by_field, @@ -174,4 +215,33 @@ export class CoreCoursesHelperProvider { }); }); } + + /** + * Show a context menu to select a course, and return the courseId and categoryId of the selected course (-1 for all courses). + * Returns an empty object if popover closed without picking a course. + * + * @param {MouseEvent} event Click event. + * @param {any[]} courses List of courses, from CoreCoursesHelperProvider.getCoursesForPopover. + * @param {number} courseId The course to select at start. + * @return {Promise<{courseId?: number, categoryId?: number}>} Promise resolved with the course ID and category ID. + */ + selectCourse(event: MouseEvent, courses: any[], courseId: number): Promise<{courseId?: number, categoryId?: number}> { + return new Promise((resolve, reject): any => { + const popover = this.popoverCtrl.create(CoreCoursePickerMenuPopoverComponent, { + courses: courses, + courseId: courseId + }); + + popover.onDidDismiss((course) => { + if (course) { + resolve({courseId: course.id, categoryId: course.category}); + } else { + resolve({}); + } + }); + popover.present({ + ev: event + }); + }); + } } diff --git a/src/core/emulator/providers/file-transfer.ts b/src/core/emulator/providers/file-transfer.ts index cfef1151209..f8ab362c90b 100644 --- a/src/core/emulator/providers/file-transfer.ts +++ b/src/core/emulator/providers/file-transfer.ts @@ -380,7 +380,7 @@ export class FileTransferObjectMock extends FileTransferObject { for (const name in params) { fd.append(name, params[name]); } - fd.append('file', file); + fd.append('file', file, fileName); xhr.send(fd); }).catch(reject); diff --git a/src/core/grades/providers/course-option-handler.ts b/src/core/grades/providers/course-option-handler.ts index ae04b557478..d544e4e856a 100644 --- a/src/core/grades/providers/course-option-handler.ts +++ b/src/core/grades/providers/course-option-handler.ts @@ -80,10 +80,10 @@ export class CoreGradesCourseOptionHandler implements CoreCourseOptionsHandler { * Returns the data needed to render the handler. * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData(injector: Injector, courseId: number): CoreCourseOptionsHandlerData | Promise { + getDisplayData(injector: Injector, course: any): CoreCourseOptionsHandlerData | Promise { return { title: 'core.grades.grades', class: 'core-grades-course-handler', diff --git a/src/core/grades/providers/helper.ts b/src/core/grades/providers/helper.ts index 5df58c9dd54..22d925f6a16 100644 --- a/src/core/grades/providers/helper.ts +++ b/src/core/grades/providers/helper.ts @@ -24,7 +24,6 @@ import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; /** @@ -38,8 +37,7 @@ export class CoreGradesHelperProvider { private gradesProvider: CoreGradesProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private utils: CoreUtilsProvider, - private linkHelper: CoreContentLinksHelperProvider, private loginHelper: CoreLoginHelperProvider, - private courseHelper: CoreCourseHelperProvider) { + private linkHelper: CoreContentLinksHelperProvider, private courseHelper: CoreCourseHelperProvider) { this.logger = logger.getInstance('CoreGradesHelperProvider'); } @@ -457,14 +455,22 @@ export class CoreGradesHelperProvider { }); } - // View own grades. Open the course with the grades tab selected. + // View own grades. Check if we already are in the course index page. + if (this.courseProvider.currentViewIsCourse(navCtrl, courseId)) { + // Current view is this course, just select the grades tab. + this.courseProvider.selectCourseTab('CoreGrades'); + + return; + } + + // Open the course with the grades tab selected. return this.courseHelper.getCourse(courseId, siteId).then((result) => { const pageParams: any = { course: result.course, selectedTab: 'CoreGrades' }; - return this.loginHelper.redirect('CoreCourseSectionPage', pageParams, siteId).catch(() => { + return this.linkHelper.goInSite(navCtrl, 'CoreCourseSectionPage', pageParams, siteId).catch(() => { // Ignore errors. }); }); diff --git a/src/core/grades/providers/overview-link-handler.ts b/src/core/grades/providers/overview-link-handler.ts index 32ab28ac111..1f34a82b016 100644 --- a/src/core/grades/providers/overview-link-handler.ts +++ b/src/core/grades/providers/overview-link-handler.ts @@ -43,7 +43,6 @@ export class CoreGradesOverviewLinkHandler extends CoreContentLinksHandlerBase { CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'CoreGradesCoursesPage', undefined, siteId); } }]; diff --git a/src/core/grades/providers/user-handler.ts b/src/core/grades/providers/user-handler.ts index 1b8e61de9db..20d1d3a49a2 100644 --- a/src/core/grades/providers/user-handler.ts +++ b/src/core/grades/providers/user-handler.ts @@ -112,7 +112,6 @@ export class CoreGradesUserHandler implements CoreUserProfileHandler { courseId: courseId, userId: user.id }; - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'CoreGradesCoursePage', pageParams); } }; diff --git a/src/core/grades/providers/user-link-handler.ts b/src/core/grades/providers/user-link-handler.ts index 738c1c0db98..a6952f917a2 100644 --- a/src/core/grades/providers/user-link-handler.ts +++ b/src/core/grades/providers/user-link-handler.ts @@ -24,7 +24,7 @@ import { CoreGradesHelperProvider } from './helper'; @Injectable() export class CoreGradesUserLinkHandler extends CoreContentLinksHandlerBase { name = 'CoreGradesUserLinkHandler'; - pattern = /\/grade\/report\/user\/index.php/; + pattern = /\/grade\/report(\/user)?\/index.php/; constructor(private gradesProvider: CoreGradesProvider, private gradesHelper: CoreGradesHelperProvider) { super(); diff --git a/src/core/login/lang/en.json b/src/core/login/lang/en.json index 8264d9196ef..4328c6d3ec2 100644 --- a/src/core/login/lang/en.json +++ b/src/core/login/lang/en.json @@ -11,6 +11,8 @@ "createaccount": "Create my new account", "createuserandpass": "Choose your username and password", "credentialsdescription": "Please provide your username and password to log in.", + "connecttomoodleapp": "You are trying to connect to a regular Moodle site. Please download the official Moodle app to access this site.", + "connecttoworkplaceapp": "You are trying to connect to a Moodle Workplace site. Please download the Moodle Workplace app to access this site.", "emailconfirmsent": "

An email should have been sent to your address at {{$a}}

\n

It contains easy instructions to complete your registration.

\n

If you continue to have difficulty, contact the site administrator.

", "emailconfirmsentnoemail": "

An email should have been sent to your address.

It contains easy instructions to complete your registration.

If you continue to have difficulty, contact the site administrator.

", "emailconfirmsentsuccess": "Confirmation email sent successfully", @@ -35,7 +37,7 @@ "invalidurl": "Invalid URL specified", "invalidvaluemax": "The maximum value is {{$a}}", "invalidvaluemin": "The minimum value is {{$a}}", - "legacymoodleversion": "You are trying to connect to an unsupported Moodle version. Please, download the Moodle Classic app to access this Moodle site.", + "legacymoodleversion": "You are trying to connect to an unsupported Moodle version. Please download the Moodle Classic app to access this Moodle site.", "legacymoodleversiondesktop": "You are trying to connect to {{$a}}.

This site is running an outdated unsupported version of Moodle which will not work with this Moodle Desktop App.

If this is your site please contact your local moodle partner to get assistance to update it.

See our contact page to submit a request for assistance.", "legacymoodleversiondesktopdownloadold": "

Alternatively, you can still access this site using an unsupported version of the app that can be downloaded from here.", "localmobileunexpectedresponse": "Moodle Mobile Additional Features check returned an unexpected response. You will be authenticated using the standard mobile service.", diff --git a/src/core/login/login.module.ts b/src/core/login/login.module.ts index 55d40cff9b9..cb1a902ec8d 100644 --- a/src/core/login/login.module.ts +++ b/src/core/login/login.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; import { CoreLoginHelperProvider } from './providers/helper'; +import { CoreLoginSitesPageModule } from './pages/sites/sites.module'; // List of providers. export const CORE_LOGIN_PROVIDERS = [ @@ -24,6 +25,7 @@ export const CORE_LOGIN_PROVIDERS = [ declarations: [ ], imports: [ + CoreLoginSitesPageModule ], providers: CORE_LOGIN_PROVIDERS }) diff --git a/src/core/login/pages/credentials/credentials.ts b/src/core/login/pages/credentials/credentials.ts index 20b3d2116dc..fbf6eb57475 100644 --- a/src/core/login/pages/credentials/credentials.ts +++ b/src/core/login/pages/credentials/credentials.ts @@ -19,7 +19,6 @@ import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLoginHelperProvider } from '../../providers/helper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { CoreConfigConstants } from '../../../../configconstants'; @@ -53,7 +52,7 @@ export class CoreLoginCredentialsPage { constructor(private navCtrl: NavController, navParams: NavParams, fb: FormBuilder, private appProvider: CoreAppProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, - private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider, + private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private eventsProvider: CoreEventsProvider) { this.siteUrl = navParams.get('siteUrl'); @@ -82,6 +81,13 @@ export class CoreLoginCredentialsPage { } } + /** + * View enter. + */ + ionViewDidEnter(): void { + this.viewLeft = false; + } + /** * View left. */ @@ -221,6 +227,9 @@ export class CoreLoginCredentialsPage { }); }).catch((error) => { this.loginHelper.treatUserTokenError(siteUrl, error, username, password); + if (error.loggedout) { + this.navCtrl.setRoot('CoreLoginSitesPage'); + } }).finally(() => { modal.dismiss(); }); @@ -230,26 +239,7 @@ export class CoreLoginCredentialsPage { * Forgotten password button clicked. */ forgottenPassword(): void { - if (this.siteConfig && this.siteConfig.forgottenpasswordurl) { - // URL set, open it. - this.utils.openInApp(this.siteConfig.forgottenpasswordurl); - - return; - } - - // Check if password reset can be done through the app. - const modal = this.domUtils.showModalLoading(); - this.loginHelper.canRequestPasswordReset(this.siteUrl).then((canReset) => { - if (canReset) { - this.navCtrl.push('CoreLoginForgottenPasswordPage', { - siteUrl: this.siteUrl, username: this.credForm.value.username - }); - } else { - this.loginHelper.openForgottenPassword(this.siteUrl); - } - }).finally(() => { - modal.dismiss(); - }); + this.loginHelper.forgottenPasswordClicked(this.navCtrl, this.siteUrl, this.credForm.value.username, this.siteConfig); } /** diff --git a/src/core/login/pages/init/init.html b/src/core/login/pages/init/init.html index 53461969c4e..a76c4916667 100644 --- a/src/core/login/pages/init/init.html +++ b/src/core/login/pages/init/init.html @@ -1,8 +1,5 @@ diff --git a/src/core/login/pages/init/init.scss b/src/core/login/pages/init/init.scss index 79f1005d0bd..f50f047fe3a 100644 --- a/src/core/login/pages/init/init.scss +++ b/src/core/login/pages/init/init.scss @@ -1,34 +1,30 @@ +$core-splash-bgsize: 100vmax !default; +$core-splash-spinner-color: $core-init-screen-spinner-color !default; +$core-splash-bgcolor: $core-color-init-screen !default; + ion-app.app-root page-core-login-init { .scroll-content { - background-color: $core-color-init-screen; /* Change this to add a bg image or change color */ - background: -webkit-radial-gradient($core-color-init-screen-alt, $core-color-init-screen); - background: radial-gradient($core-color-init-screen-alt, $core-color-init-screen); - background-repeat: no-repeat; - background-position: center center; - } - .core-bglogo { + background: $core-splash-bgcolor; /* Change this to add a bg image or change color */ + overflow: hidden; position: absolute; @include position(0, 0, 0, 0); height: 100%; width: 100%; display: table; + } + .core-bglogo { + display: table-cell; + text-align: center; + vertical-align: middle; - .core-logo { - display: table-cell; - text-align: center; - vertical-align: middle; - } - - img { - width: $core-init-screen-logo-width; - max-width: $core-init-screen-logo-max-width; - display: block; - margin: 0 auto; - margin-bottom: 30px; - } + background-image: url("#{$assets-path}/img/splash.png"); + background-repeat: no-repeat; + background-size: 100%; + background-size: $core-splash-bgsize; + background-position: center; .spinner circle, .spinner line { - stroke: $core-init-screen-spinner-color; + stroke: $core-splash-spinner-color; } } } diff --git a/src/core/login/pages/init/init.ts b/src/core/login/pages/init/init.ts index a33c0b2d5c4..3cadcccfbcd 100644 --- a/src/core/login/pages/init/init.ts +++ b/src/core/login/pages/init/init.ts @@ -94,14 +94,8 @@ export class CoreLoginInitPage { return this.loadPage(); }); } - } else { - return this.sitesProvider.hasSites().then((hasSites) => { - if (hasSites) { - return this.navCtrl.setRoot('CoreLoginSitesPage'); - } else { - return this.loginHelper.goToAddSite(true); - } - }); } + + return this.navCtrl.setRoot('CoreLoginSitesPage'); } } diff --git a/src/core/login/pages/reconnect/reconnect.html b/src/core/login/pages/reconnect/reconnect.html index ce0edcde673..44955e85aff 100644 --- a/src/core/login/pages/reconnect/reconnect.html +++ b/src/core/login/pages/reconnect/reconnect.html @@ -39,7 +39,7 @@ - {{ 'core.login.cancel' | translate }} + {{ 'core.login.cancel' | translate }} @@ -49,6 +49,11 @@ + +
+ +
+ {{ 'core.login.potentialidps' | translate }} diff --git a/src/core/login/pages/reconnect/reconnect.ts b/src/core/login/pages/reconnect/reconnect.ts index 4114678c4d9..c653e51a652 100644 --- a/src/core/login/pages/reconnect/reconnect.ts +++ b/src/core/login/pages/reconnect/reconnect.ts @@ -101,11 +101,16 @@ export class CoreLoginReconnectPage { /** * Cancel reconnect. + * + * @param {Event} [e] Event. */ - cancel(): void { - this.sitesProvider.logout().finally(() => { - this.navCtrl.setRoot('CoreLoginSitesPage'); - }); + cancel(e?: Event): void { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + this.sitesProvider.logout(); } /** @@ -149,18 +154,34 @@ export class CoreLoginReconnectPage { // Go to the site initial page. return this.loginHelper.goToSiteInitialPage(this.navCtrl, this.pageName, this.pageParams); }).catch((error) => { + if (error.loggedout) { + this.loginHelper.treatUserTokenError(siteUrl, error, username, password); + } else { + this.domUtils.showErrorModalDefault(error, 'core.login.errorupdatesite', true); + } + // Error, go back to login page. - this.domUtils.showErrorModalDefault(error, 'core.login.errorupdatesite', true); this.cancel(); }); }); }).catch((error) => { this.loginHelper.treatUserTokenError(siteUrl, error, username, password); + + if (error.loggedout) { + this.cancel(); + } }).finally(() => { modal.dismiss(); }); } + /** + * Forgotten password button clicked. + */ + forgottenPassword(): void { + this.loginHelper.forgottenPasswordClicked(this.navCtrl, this.siteUrl, this.credForm.value.username, this.siteConfig); + } + /** * An OAuth button was clicked. * diff --git a/src/core/login/pages/site-error/site-error.html b/src/core/login/pages/site-error/site-error.html index 110a93a7232..da3c478fef2 100644 --- a/src/core/login/pages/site-error/site-error.html +++ b/src/core/login/pages/site-error/site-error.html @@ -20,7 +20,7 @@

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

{{ 'core.login.contactyouradministratorissue' | translate:{$a: ''} }}

-

+

diff --git a/src/core/login/pages/site/site.ts b/src/core/login/pages/site/site.ts index c4bd86fdad1..856797acdb7 100644 --- a/src/core/login/pages/site/site.ts +++ b/src/core/login/pages/site/site.ts @@ -95,10 +95,16 @@ export class CoreLoginSitePage { return this.sitesProvider.newSite(data.siteUrl, data.token, data.privateToken).then(() => { return this.loginHelper.goToSiteInitialPage(); }, (error) => { - this.domUtils.showErrorModal(error); + this.loginHelper.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); + if (error.loggedout) { + this.navCtrl.setRoot('CoreLoginSitesPage'); + } }); }, (error) => { this.loginHelper.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); + if (error.loggedout) { + this.navCtrl.setRoot('CoreLoginSitesPage'); + } }).finally(() => { modal.dismiss(); }); @@ -154,10 +160,14 @@ export class CoreLoginSitePage { * Show an error that aims people to solve the issue. * * @param {string} url The URL the user was trying to connect to. - * @param {string} error Error to display. + * @param {any} error Error to display. */ - protected showLoginIssue(url: string, error: string): void { - const modal = this.modalCtrl.create('CoreLoginSiteErrorPage', { siteUrl: url, issue: error }); + protected showLoginIssue(url: string, error: any): void { + const modal = this.modalCtrl.create('CoreLoginSiteErrorPage', { + siteUrl: url, + issue: this.domUtils.getErrorMessage(error) + }); + modal.present(); } } diff --git a/src/core/login/pages/sites/sites.module.ts b/src/core/login/pages/sites/sites.module.ts index 72b515f3822..7913b8d6dc0 100644 --- a/src/core/login/pages/sites/sites.module.ts +++ b/src/core/login/pages/sites/sites.module.ts @@ -27,5 +27,8 @@ import { CoreDirectivesModule } from '@directives/directives.module'; IonicPageModule.forChild(CoreLoginSitesPage), TranslateModule.forChild() ], + entryComponents: [ + CoreLoginSitesPage + ] }) export class CoreLoginSitesPageModule {} diff --git a/src/core/login/pages/sites/sites.ts b/src/core/login/pages/sites/sites.ts index 5f6c8a8c0cd..2fcbc96abde 100644 --- a/src/core/login/pages/sites/sites.ts +++ b/src/core/login/pages/sites/sites.ts @@ -46,6 +46,10 @@ export class CoreLoginSitesPage { */ ionViewDidLoad(): void { this.sitesProvider.getSortedSites().then((sites) => { + if (sites.length == 0) { + this.loginHelper.goToAddSite(true); + } + // Remove protocol from the url to show more url text. this.sites = sites.map((site) => { site.siteUrl = site.siteUrl.replace(/^https?:\/\//, ''); diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index 2d4bbc62f61..9d46e536a55 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -260,6 +260,38 @@ export class CoreLoginHelperProvider { }); } + /** + * Helper function to act when the forgotten password is clicked. + * + * @param {NavController} navCtrl NavController to use to navigate. + * @param {string} siteUrl Site URL. + * @param {string} username Username. + * @param {any} [siteConfig] Site config. + */ + forgottenPasswordClicked(navCtrl: NavController, siteUrl: string, username: string, siteConfig?: any): void { + if (siteConfig && siteConfig.forgottenpasswordurl) { + // URL set, open it. + this.utils.openInApp(siteConfig.forgottenpasswordurl); + + return; + } + + // Check if password reset can be done through the app. + const modal = this.domUtils.showModalLoading(); + + this.canRequestPasswordReset(siteUrl).then((canReset) => { + if (canReset) { + navCtrl.push('CoreLoginForgottenPasswordPage', { + siteUrl: siteUrl, username: username + }); + } else { + this.openForgottenPassword(siteUrl); + } + }).finally(() => { + modal.dismiss(); + }); + } + /** * Format profile fields, filtering the ones that shouldn't be shown on signup and classifying them in categories. * @@ -629,7 +661,7 @@ export class CoreLoginHelperProvider { * @param {string} page Name of the page to load. * @param {any} params Params to pass to the page. */ - protected loadPageInMainMenu(page: string, params: any): void { + loadPageInMainMenu(page: string, params: any): void { if (!this.appProvider.isMainMenuOpen()) { // Main menu not open. Store the page to be loaded later. this.pageToLoad = { @@ -997,34 +1029,81 @@ export class CoreLoginHelperProvider { * @param {string} message The warning message. */ protected showLegacyNoticeModal(message: string): void { - const isAndroid = this.platform.is('android'), - isIOS = this.platform.is('ios'), - isWindows = this.appProvider.isWindows(), - isLinux = this.appProvider.isLinux(), - buttons: any[] = [ + let link; + + if (this.appProvider.isWindows()) { + link = 'https://download.moodle.org/desktop/download.php?platform=windows&version=342'; + } else if (this.appProvider.isLinux()) { + link = 'https://download.moodle.org/desktop/download.php?platform=linux&version=342&arch=' + + (this.appProvider.is64Bits() ? '64' : '32'); + } else if (this.platform.is('android')) { + link = 'market://details?id=com.moodle.classic'; + } else if (this.platform.is('ios')) { + link = 'itms-apps://itunes.apple.com/app/id1403448117'; + } + + this.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal warning the user that he should use the Workplace app. + * + * @param {string} message The warning message. + */ + protected showWorkplaceNoticeModal(message: string): void { + let link; + + if (this.platform.is('android')) { + link = 'market://details?id=com.moodle.workplace'; + } else if (this.platform.is('ios')) { + link = 'itms-apps://itunes.apple.com/app/id1470929705'; + } + + this.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal warning the user that he should use the current Moodle app. + * + * @param {string} message The warning message. + */ + protected showMoodleAppNoticeModal(message: string): void { + let link; + + if (this.appProvider.isWindows()) { + link = 'https://download.moodle.org/desktop/download.php?platform=windows'; + } else if (this.appProvider.isLinux()) { + link = 'https://download.moodle.org/desktop/download.php?platform=linux&arch=' + + (this.appProvider.is64Bits() ? '64' : '32'); + } else if (this.appProvider.isMac()) { + link = 'itms-apps://itunes.apple.com/app/id1255924440'; + } else if (this.platform.is('android')) { + link = 'market://details?id=com.moodle.moodlemobile'; + } else if (this.platform.is('ios')) { + link = 'itms-apps://itunes.apple.com/app/id633359593'; + } + + this.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal warning the user that he should use a different app. + * + * @param {string} message The warning message. + * @param {string} link Link to the app to download if any. + */ + protected showDownloadAppNoticeModal(message: string, link?: string): void { + const buttons: any[] = [ { text: this.translate.instant('core.ok'), role: 'cancel' } ]; - if (isAndroid || isIOS || isWindows || isLinux) { + if (link) { buttons.push({ text: this.translate.instant('core.download'), handler: (): void => { - let link; - - if (isWindows) { - link = 'https://download.moodle.org/desktop/download.php?platform=windows&version=342'; - } else if (isLinux) { - link = 'https://download.moodle.org/desktop/download.php?platform=linux&version=342&arch=' + - (this.appProvider.is64Bits() ? '64' : '32'); - } else if (isAndroid) { - link = 'market://details?id=com.moodle.classic'; - } else { - link = 'itms-apps://itunes.apple.com/app/id1403448117'; - } - this.utils.openInBrowser(link); } }); @@ -1036,7 +1115,8 @@ export class CoreLoginHelperProvider { }); alert.present().then(() => { - if (!isAndroid && !isIOS) { + const isDevice = this.platform.is('android') || this.platform.is('ios'); + if (!isDevice) { // Treat all anchors so they don't override the app. const alertMessageEl: HTMLElement = alert.pageRef().nativeElement.querySelector('.alert-message'); this.domUtils.treatAnchors(alertMessageEl); @@ -1147,6 +1227,10 @@ export class CoreLoginHelperProvider { this.showNotConfirmedModal(siteUrl, undefined, username, password); } else if (error.errorcode == 'legacymoodleversion') { this.showLegacyNoticeModal(this.textUtils.getErrorMessageFromError(error)); + } else if (error.errorcode == 'connecttomoodleapp') { + this.showMoodleAppNoticeModal(this.textUtils.getErrorMessageFromError(error)); + } else if (error.errorcode == 'connecttoworkplaceapp') { + this.showWorkplaceNoticeModal(this.textUtils.getErrorMessageFromError(error)); } else { this.domUtils.showErrorModal(error); } diff --git a/src/core/mainmenu/pages/more/more.html b/src/core/mainmenu/pages/more/more.html index e723bc44af7..60ccc28a4b4 100644 --- a/src/core/mainmenu/pages/more/more.html +++ b/src/core/mainmenu/pages/more/more.html @@ -13,7 +13,7 @@ - +

{{ handler.title | translate}}

{{handler.badge}} diff --git a/src/core/mainmenu/providers/mainmenu.ts b/src/core/mainmenu/providers/mainmenu.ts index 5f8fe59ed9a..d601956c427 100644 --- a/src/core/mainmenu/providers/mainmenu.ts +++ b/src/core/mainmenu/providers/mainmenu.ts @@ -16,7 +16,9 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; import { CoreLangProvider } from '@providers/lang'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreConfigConstants } from '../../../configconstants'; +import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from './delegate'; /** * Custom main menu item. @@ -56,10 +58,34 @@ export class CoreMainMenuProvider { static ITEM_MIN_WIDTH = 72; // Min with of every item, based on 5 items on a 360 pixel wide screen. protected tablet = false; - constructor(private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider) { + constructor(private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider, + protected menuDelegate: CoreMainMenuDelegate, protected utils: CoreUtilsProvider) { this.tablet = window && window.innerWidth && window.innerWidth >= 576 && window.innerHeight >= 576; } + /** + * Get the current main menu handlers. + * + * @return {Promise} Promise resolved with the current main menu handlers. + */ + getCurrentMainMenuHandlers(): Promise { + const deferred = this.utils.promiseDefer(); + + const subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { + subscription && subscription.unsubscribe(); + + // Remove the handlers that should only appear in the More menu. + handlers = handlers.filter((handler) => { + return !handler.onlyInMore; + }); + + // Return main handlers. + deferred.resolve(handlers.slice(0, this.getNumItems())); + }); + + return deferred.promise; + } + /** * Get a list of custom menu items for a certain site. * @@ -211,6 +237,23 @@ export class CoreMainMenuProvider { return tablet ? 'side' : 'bottom'; } + /** + * Check if a certain page is the root of a main menu handler currently displayed. + * + * @param {string} page Name of the page. + * @param {string} [pageParams] Page params. + * @return {Promise} Promise resolved with boolean: whether it's the root of a main menu handler. + */ + isCurrentMainMenuHandler(pageName: string, pageParams?: any): Promise { + return this.getCurrentMainMenuHandlers().then((handlers) => { + const handler = handlers.find((handler, i) => { + return handler.page == pageName; + }); + + return !!handler; + }); + } + /** * Check if responsive main menu items is disabled in the current site. * diff --git a/src/core/pushnotifications/providers/pushnotifications.ts b/src/core/pushnotifications/providers/pushnotifications.ts index f6b0201d4ac..0985c6b1820 100644 --- a/src/core/pushnotifications/providers/pushnotifications.ts +++ b/src/core/pushnotifications/providers/pushnotifications.ts @@ -240,6 +240,27 @@ export class CorePushNotificationsProvider { }); } + /** + * Enable or disable Firebase analytics. + * + * @param {boolean} enable Whether to enable or disable. + * @return {Promise} Promise resolved when done. + */ + enableAnalytics(enable: boolean): Promise { + const win = window; // This feature is only present in our fork of the plugin. + + if (CoreConfigConstants.enableanalytics && win.PushNotification && win.PushNotification.enableAnalytics) { + return new Promise((resolve, reject): void => { + win.PushNotification.enableAnalytics(resolve, (error) => { + this.logger.error('Error enabling or disabling Firebase analytics', enable, error); + resolve(); + }, !!enable); + }); + } + + return Promise.resolve(); + } + /** * Returns options for push notifications based on device. * @@ -321,11 +342,17 @@ export class CorePushNotificationsProvider { const win = window; // This feature is only present in our fork of the plugin. if (CoreConfigConstants.enableanalytics && win.PushNotification && win.PushNotification.logEvent) { - return new Promise((resolve, reject): void => { - win.PushNotification.logEvent(resolve, (error) => { - this.logger.error('Error logging firebase event', name, error); - resolve(); - }, name, data, !!filter); + + // Check if the analytics is enabled by the user. + return this.configProvider.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true).then((enabled) => { + if (enabled) { + return new Promise((resolve, reject): void => { + win.PushNotification.logEvent(resolve, (error) => { + this.logger.error('Error logging firebase event', name, error); + resolve(); + }, name, data, !!filter); + }); + } }); } diff --git a/src/core/question/components/question/question.ts b/src/core/question/components/question/question.ts index fbb31b6cf87..11b276ce4e7 100644 --- a/src/core/question/components/question/question.ts +++ b/src/core/question/components/question/question.ts @@ -64,7 +64,7 @@ export class CoreQuestionComponent implements OnInit { ngOnInit(): void { this.offlineEnabled = this.utils.isTrueOrOne(this.offlineEnabled); - if (!this.question) { + if (!this.question || (this.question.type != 'random' && !this.questionDelegate.isQuestionSupported(this.question.type))) { this.loaded = true; return; @@ -145,6 +145,8 @@ export class CoreQuestionComponent implements OnInit { this.questionHelper.extractQuestionFeedback(this.question); this.questionHelper.extractQuestionComment(this.question); }); + } else { + this.loaded = true; } }).catch(() => { // Ignore errors. diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 01f85c86e79..e564a97d804 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -67,6 +67,11 @@ export class CoreQuestionHelperProvider { * @param {string} [selector] Selector to search the buttons. By default, '.im-controls input[type="submit"]'. */ extractQbehaviourButtons(question: any, selector?: string): void { + if (this.questionDelegate.getPreventSubmitMessage(question)) { + // The question is not fully supported, don't extract the buttons. + return; + } + selector = selector || '.im-controls input[type="submit"]'; const element = this.domUtils.convertToElement(question.html); @@ -76,8 +81,6 @@ export class CoreQuestionHelperProvider { buttons.forEach((button) => { this.addBehaviourButton(question, button); }); - - question.html = element.innerHTML; } /** diff --git a/src/core/settings/lang/en.json b/src/core/settings/lang/en.json index d2b37188734..f0e09d02d5a 100644 --- a/src/core/settings/lang/en.json +++ b/src/core/settings/lang/en.json @@ -21,6 +21,8 @@ "disabled": "Disabled", "displayformat": "Display format", "enabledownloadsection": "Enable download sections", + "enablefirebaseanalytics": "Enable Firebase analytics", + "enablefirebaseanalyticsdescription": "If enabled, the app will collect anonymous data usage.", "enablerichtexteditor": "Enable text editor", "enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.", "enablesyncwifi": "Allow sync only when on Wi-Fi", @@ -28,6 +30,8 @@ "errorsyncsite": "Error synchronising site data. Please check your Internet connection and try again.", "estimatedfreespace": "Estimated free space", "filesystemroot": "File system root", + "fontsize": "Text size", + "fontsizecharacter": "A", "general": "General", "language": "Language", "license": "Licence", @@ -54,4 +58,4 @@ "versioncode": "Version code", "versionname": "Version name", "wificonnection": "Wi-Fi connection" -} \ No newline at end of file +} diff --git a/src/core/settings/pages/general/general.html b/src/core/settings/pages/general/general.html index 2358295dedd..0e141881376 100644 --- a/src/core/settings/pages/general/general.html +++ b/src/core/settings/pages/general/general.html @@ -7,9 +7,19 @@

{{ 'core.settings.language' | translate }}

- {{ languages[code] }} + {{ entry.name }}
+ +

{{ 'core.settings.fontsize' | translate }}

+ + + {{ 'core.settings.fontsizecharacter' | translate }} + + +

{{ 'core.settings.enablerichtexteditor' | translate }}

@@ -24,4 +34,11 @@

{{ 'core.settings.debugdisplay' | translate }}

+ + +

{{ 'core.settings.enablefirebaseanalytics' | translate }}

+

{{ 'core.settings.enablefirebaseanalyticsdescription' | translate }}

+
+ +
diff --git a/src/core/settings/pages/general/general.ts b/src/core/settings/pages/general/general.ts index e6fbd925647..9befe638a1d 100644 --- a/src/core/settings/pages/general/general.ts +++ b/src/core/settings/pages/general/general.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, } from '@angular/core'; -import { IonicPage } from 'ionic-angular'; +import { Component, ViewChild } from '@angular/core'; +import { IonicPage, Segment } from 'ionic-angular'; import { CoreAppProvider } from '@providers/app'; import { CoreConstants } from '@core/constants'; import { CoreConfigProvider } from '@providers/config'; @@ -22,6 +22,7 @@ import { CoreEventsProvider } from '@providers/events'; import { CoreLangProvider } from '@providers/lang'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; import { CoreConfigConstants } from '../../../../configconstants'; /** @@ -34,24 +35,57 @@ import { CoreConfigConstants } from '../../../../configconstants'; }) export class CoreSettingsGeneralPage { - languages = {}; - languageCodes = []; + languages = []; selectedLanguage: string; + fontSizes = []; + selectedFontSize: string; rteSupported: boolean; richTextEditor: boolean; debugDisplay: boolean; + analyticsSupported: boolean; + analyticsEnabled: boolean; constructor(appProvider: CoreAppProvider, private configProvider: CoreConfigProvider, fileProvider: CoreFileProvider, private eventsProvider: CoreEventsProvider, private langProvider: CoreLangProvider, - private domUtils: CoreDomUtilsProvider, + private domUtils: CoreDomUtilsProvider, private pushNotificationsProvider: CorePushNotificationsProvider, localNotificationsProvider: CoreLocalNotificationsProvider) { - this.languages = CoreConfigConstants.languages; - this.languageCodes = Object.keys(this.languages); + // Get the supported languages. + const languages = CoreConfigConstants.languages; + for (const code in languages) { + this.languages.push({ + code: code, + name: languages[code] + }); + } + + // Sort them by name. + this.languages.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + langProvider.getCurrentLanguage().then((currentLanguage) => { this.selectedLanguage = currentLanguage; }); + this.configProvider.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConfigConstants.font_sizes[0].toString()).then((fontSize) => { + this.selectedFontSize = fontSize; + this.fontSizes = CoreConfigConstants.font_sizes.map((size) => { + return { + size: size, + // Absolute pixel size based on 1.4rem body text when this size is selected. + style: Math.round(size * 16 * 1.4 / 100), + selected: size === this.selectedFontSize + }; + }); + // Workaround for segment control bug https://github.com/ionic-team/ionic/issues/6923, fixed in Ionic 4 only. + setTimeout(() => { + if (this.segment) { + this.segment.ngAfterContentInit(); + } + }); + }); + this.rteSupported = this.domUtils.isRichTextEditorSupported(); if (this.rteSupported) { this.configProvider.get(CoreConstants.SETTINGS_RICH_TEXT_EDITOR, true).then((richTextEditorEnabled) => { @@ -62,8 +96,18 @@ export class CoreSettingsGeneralPage { this.configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { this.debugDisplay = !!debugDisplay; }); + + this.analyticsSupported = CoreConfigConstants.enableanalytics; + if (this.analyticsSupported) { + this.configProvider.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true).then((enabled) => { + this.analyticsEnabled = !!enabled; + }); + } } + @ViewChild(Segment) + private segment: Segment; + /** * Called when a new language is selected. */ @@ -73,6 +117,19 @@ export class CoreSettingsGeneralPage { }); } + /** + * Called when a new font size is selected. + */ + fontSizeChanged(): void { + this.fontSizes = this.fontSizes.map((fontSize) => { + fontSize.selected = fontSize.size === this.selectedFontSize; + + return fontSize; + }); + document.documentElement.style.fontSize = this.selectedFontSize + '%'; + this.configProvider.set(CoreConstants.SETTINGS_FONT_SIZE, this.selectedFontSize); + } + /** * Called when the rich text editor is enabled or disabled. */ @@ -87,4 +144,13 @@ export class CoreSettingsGeneralPage { this.configProvider.set(CoreConstants.SETTINGS_DEBUG_DISPLAY, this.debugDisplay ? 1 : 0); this.domUtils.setDebugDisplay(this.debugDisplay); } + + /** + * Called when the analytics setting is enabled or disabled. + */ + analyticsEnabledChanged(): void { + this.pushNotificationsProvider.enableAnalytics(this.analyticsEnabled).then(() => { + this.configProvider.set(CoreConstants.SETTINGS_ANALYTICS_ENABLED, this.analyticsEnabled ? 1 : 0); + }); + } } diff --git a/src/core/sitehome/components/index/core-sitehome-index.html b/src/core/sitehome/components/index/core-sitehome-index.html index a8a1ed1bb86..7bc8292fe2f 100644 --- a/src/core/sitehome/components/index/core-sitehome-index.html +++ b/src/core/sitehome/components/index/core-sitehome-index.html @@ -1,32 +1,28 @@ - + + + + + + + + - - - - - - + + - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - -
- - - + + + diff --git a/src/core/sitehome/components/index/index.ts b/src/core/sitehome/components/index/index.ts index 34aa44057a6..8c8a511c1ea 100644 --- a/src/core/sitehome/components/index/index.ts +++ b/src/core/sitehome/components/index/index.ts @@ -12,14 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, ViewChildren, QueryList } from '@angular/core'; +import { Component, OnInit, Input, ViewChild } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; -import { CoreBlockDelegate } from '@core/block/providers/delegate'; -import { CoreBlockComponent } from '@core/block/components/block/block'; +import { CoreBlockCourseBlocksComponent } from '@core/block/components/course-blocks/course-blocks'; import { CoreSite } from '@classes/site'; /** @@ -30,21 +29,19 @@ import { CoreSite } from '@classes/site'; templateUrl: 'core-sitehome-index.html', }) export class CoreSiteHomeIndexComponent implements OnInit { - @ViewChildren(CoreBlockComponent) blocksComponents: QueryList; + @Input() downloadEnabled: boolean; + @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent: CoreBlockCourseBlocksComponent; dataLoaded = false; section: any; hasContent: boolean; - hasSupportedBlock: boolean; items: any[] = []; siteHomeId: number; currentSite: CoreSite; - blocks = []; - downloadEnabled: boolean; constructor(private domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider, private courseProvider: CoreCourseProvider, private courseHelper: CoreCourseHelperProvider, - private prefetchDelegate: CoreCourseModulePrefetchDelegate, private blockDelegate: CoreBlockDelegate) { + private prefetchDelegate: CoreCourseModulePrefetchDelegate) { this.currentSite = sitesProvider.getCurrentSite(); this.siteHomeId = this.currentSite.getSiteHomeId(); } @@ -53,7 +50,6 @@ export class CoreSiteHomeIndexComponent implements OnInit { * Component being initialized. */ ngOnInit(): void { - this.downloadEnabled = !this.currentSite.isOfflineDisabled(); this.loadContent().finally(() => { this.dataLoaded = true; }); @@ -80,19 +76,15 @@ export class CoreSiteHomeIndexComponent implements OnInit { promises.push(this.prefetchDelegate.invalidateModules(this.section.modules, this.siteHomeId)); } - if (this.courseProvider.canGetCourseBlocks()) { - promises.push(this.courseProvider.invalidateCourseBlocks(this.siteHomeId)); - } - - // Invalidate the blocks. - this.blocksComponents.forEach((blockComponent) => { - promises.push(blockComponent.invalidate().catch(() => { - // Ignore errors. - })); - }); + promises.push(this.courseBlocksComponent.invalidateBlocks()); Promise.all(promises).finally(() => { - this.loadContent().finally(() => { + const p2 = []; + + p2.push(this.loadContent()); + p2.push(this.courseBlocksComponent.loadContent()); + + return Promise.all(p2).finally(() => { refresher.complete(); }); }); @@ -150,32 +142,6 @@ export class CoreSiteHomeIndexComponent implements OnInit { this.currentSite && this.currentSite.getInfo().sitename).catch(() => { // Ignore errors. }); - - // Get site home blocks. - const canGetBlocks = this.courseProvider.canGetCourseBlocks(), - promise = canGetBlocks ? this.courseProvider.getCourseBlocks(this.siteHomeId) : Promise.reject(null); - - return promise.then((blocks) => { - this.blocks = blocks; - this.hasSupportedBlock = this.blockDelegate.hasSupportedBlock(blocks); - - }).catch((error) => { - if (canGetBlocks) { - this.domUtils.showErrorModal(error); - } - this.blocks = []; - - // Cannot get the blocks, just show site main menu if needed. - const section = sections.find((section) => section.section == 0); - if (section && this.courseHelper.sectionHasContent(section)) { - this.blocks.push({ - name: 'site_main_menu' - }); - this.hasSupportedBlock = true; - } else { - this.hasSupportedBlock = false; - } - }); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true); }); diff --git a/src/core/sitehome/providers/index-link-handler.ts b/src/core/sitehome/providers/index-link-handler.ts index eb2cb59060f..f0e15ad317a 100644 --- a/src/core/sitehome/providers/index-link-handler.ts +++ b/src/core/sitehome/providers/index-link-handler.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreSiteHomeProvider } from './sitehome'; /** @@ -29,7 +29,7 @@ export class CoreSiteHomeIndexLinkHandler extends CoreContentLinksHandlerBase { pattern = /\/course\/view\.php.*([\?\&]id=\d+)/; constructor(private sitesProvider: CoreSitesProvider, private siteHomeProvider: CoreSiteHomeProvider, - private loginHelper: CoreLoginHelperProvider) { + private linkHelper: CoreContentLinksHelperProvider) { super(); } @@ -46,8 +46,7 @@ export class CoreSiteHomeIndexLinkHandler extends CoreContentLinksHandlerBase { CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect('CoreSiteHomeIndexPage', undefined, siteId); + this.linkHelper.goInSite(navCtrl, 'CoreSiteHomeIndexPage', undefined, siteId); } }]; } diff --git a/src/core/siteplugins/classes/call-ws-directive.ts b/src/core/siteplugins/classes/call-ws-directive.ts index 8716afdd040..08dac6c7f40 100644 --- a/src/core/siteplugins/classes/call-ws-directive.ts +++ b/src/core/siteplugins/classes/call-ws-directive.ts @@ -26,8 +26,8 @@ export class CoreSitePluginsCallWSBaseDirective implements OnInit, OnDestroy { @Input() name: string; // The name of the WS to call. @Input() params: any; // The params for the WS call. @Input() preSets: any; // The preSets for the WS call. - @Input() useOtherDataForWS: any[]; // Whether to include other data in the params for the WS. - // @see CoreSitePluginsProvider.loadOtherDataInArgs. + @Input() useOtherDataForWS: any; // Whether to include other data in the params for the WS. + // @see CoreSitePluginsProvider.loadOtherDataInArgs. @Input() form: string; // ID or name to identify a form. The form will be obtained from document.forms. // If supplied and form is found, the form data will be retrieved and sent to the WS. @Output() onSuccess: EventEmitter = new EventEmitter(); // Sends the result when the WS call succeeds. diff --git a/src/core/siteplugins/classes/handlers/block-handler.ts b/src/core/siteplugins/classes/handlers/block-handler.ts index 8ee9dd059f8..b4734d36ead 100644 --- a/src/core/siteplugins/classes/handlers/block-handler.ts +++ b/src/core/siteplugins/classes/handlers/block-handler.ts @@ -15,14 +15,17 @@ import { Injector } from '@angular/core'; import { CoreSitePluginsBaseHandler } from './base-handler'; import { CoreBlockHandler, CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreBlockPreRenderedComponent } from '@core/block/components/pre-rendered-block/pre-rendered-block'; import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/block/block'; +import { CoreSitePluginsOnlyTitleBlockComponent } from '@core/siteplugins/components/only-title-block/only-title-block'; /** * Handler to support a block using a site plugin. */ export class CoreSitePluginsBlockHandler extends CoreSitePluginsBaseHandler implements CoreBlockHandler { - constructor(name: string, public blockName: string, protected handlerSchema: any, protected initResult: any) { + constructor(name: string, public title: string, public blockName: string, protected handlerSchema: any, + protected initResult: any) { super(name); } @@ -38,23 +41,27 @@ export class CoreSitePluginsBlockHandler extends CoreSitePluginsBaseHandler impl */ getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number): CoreBlockHandlerData | Promise { - let title, - className; - if (this.handlerSchema.displaydata && this.handlerSchema.displaydata.title) { - title = this.handlerSchema.displaydata.title; - } else { - title = 'plugins.block_' + block.name + '.pluginname'; - } + let className, + component; + if (this.handlerSchema.displaydata && this.handlerSchema.displaydata.class) { className = this.handlerSchema.displaydata.class; } else { className = 'block_' + block.name; } + if (this.handlerSchema.displaydata && this.handlerSchema.displaydata.type == 'title') { + component = CoreSitePluginsOnlyTitleBlockComponent; + } else if (this.handlerSchema.displaydata && this.handlerSchema.displaydata.type == 'prerendered') { + component = CoreBlockPreRenderedComponent; + } else { + component = CoreSitePluginsBlockComponent; + } + return { - title: title, + title: this.title, class: className, - component: CoreSitePluginsBlockComponent + component: component }; } } diff --git a/src/core/siteplugins/classes/handlers/course-option-handler.ts b/src/core/siteplugins/classes/handlers/course-option-handler.ts index 0ecaa751543..e3fde2bd2ee 100644 --- a/src/core/siteplugins/classes/handlers/course-option-handler.ts +++ b/src/core/siteplugins/classes/handlers/course-option-handler.ts @@ -14,21 +14,29 @@ import { Injector } from '@angular/core'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; -import { CoreCourseOptionsHandler, CoreCourseOptionsHandlerData } from '@core/course/providers/options-delegate'; +import { + CoreCourseOptionsHandler, CoreCourseOptionsHandlerData, CoreCourseOptionsMenuHandlerData +} from '@core/course/providers/options-delegate'; import { CoreSitePluginsBaseHandler } from './base-handler'; import { CoreSitePluginsCourseOptionComponent } from '../../components/course-option/course-option'; +import { CoreUtilsProvider, PromiseDefer } from '@providers/utils/utils'; /** * Handler to display a site plugin in course options. */ export class CoreSitePluginsCourseOptionHandler extends CoreSitePluginsBaseHandler implements CoreCourseOptionsHandler { priority: number; + isMenuHandler: boolean; + + protected updatingDefer: PromiseDefer; constructor(name: string, protected title: string, protected plugin: any, protected handlerSchema: any, - protected initResult: any, protected sitePluginsProvider: CoreSitePluginsProvider) { + protected initResult: any, protected sitePluginsProvider: CoreSitePluginsProvider, + protected utils: CoreUtilsProvider) { super(name); this.priority = handlerSchema.priority; + this.isMenuHandler = !!handlerSchema.ismenuhandler; } /** @@ -41,18 +49,23 @@ export class CoreSitePluginsCourseOptionHandler extends CoreSitePluginsBaseHandl * @return {boolean|Promise} True or promise resolved with true if enabled. */ isEnabledForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any): boolean | Promise { - return this.sitePluginsProvider.isHandlerEnabledForCourse( - courseId, this.handlerSchema.restricttoenrolledcourses, this.initResult.restrict); + // Wait for "init" result to be updated. + const promise = this.updatingDefer ? this.updatingDefer.promise : Promise.resolve(); + + return promise.then(() => { + return this.sitePluginsProvider.isHandlerEnabledForCourse( + courseId, this.handlerSchema.restricttoenrolledcourses, this.initResult.restrict); + }); } /** - * Returns the data needed to render the handler. + * Returns the data needed to render the handler (if it isn't a menu handler). * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData(injector: Injector, courseId: number): CoreCourseOptionsHandlerData | Promise { + getDisplayData(injector: Injector, course: any): CoreCourseOptionsHandlerData | Promise { return { title: this.title, class: this.handlerSchema.displaydata.class, @@ -63,6 +76,33 @@ export class CoreSitePluginsCourseOptionHandler extends CoreSitePluginsBaseHandl }; } + /** + * Returns the data needed to render the handler (if it's a menu handler). + * + * @param {Injector} injector Injector. + * @param {any} course The course. + * @return {CoreCourseOptionsMenuHandlerData|Promise} Data or promise resolved with data. + */ + getMenuDisplayData(injector: Injector, course: any): + CoreCourseOptionsMenuHandlerData | Promise { + + return { + title: this.title, + class: this.handlerSchema.displaydata.class, + icon: this.handlerSchema.displaydata.icon || '', + page: 'CoreSitePluginsPluginPage', + pageParams: { + title: this.title, + component: this.plugin.component, + method: this.handlerSchema.method, + args: { + courseid: course.id + }, + initResult: this.initResult + } + }; + } + /** * Called when a course is downloaded. It should prefetch all the data to be able to see the plugin in offline. * @@ -77,4 +117,23 @@ export class CoreSitePluginsCourseOptionHandler extends CoreSitePluginsBaseHandl return this.sitePluginsProvider.prefetchFunctions(component, args, this.handlerSchema, course.id, undefined, true); } + + /** + * Set init result. + * + * @param {any} result Result to set. + */ + setInitResult(result: any): void { + this.initResult = result; + + this.updatingDefer.resolve(); + delete this.updatingDefer; + } + + /** + * Mark init being updated. + */ + updatingInit(): void { + this.updatingDefer = this.utils.promiseDefer(); + } } diff --git a/src/core/siteplugins/classes/handlers/user-handler.ts b/src/core/siteplugins/classes/handlers/user-handler.ts index 044fd231765..5bd01b063de 100644 --- a/src/core/siteplugins/classes/handlers/user-handler.ts +++ b/src/core/siteplugins/classes/handlers/user-handler.ts @@ -16,6 +16,7 @@ import { NavController } from 'ionic-angular'; import { CoreUserDelegate, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@core/user/providers/user-delegate'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsBaseHandler } from './base-handler'; +import { CoreUtilsProvider, PromiseDefer } from '@providers/utils/utils'; /** * Handler to display a site plugin in the user profile. @@ -37,8 +38,11 @@ export class CoreSitePluginsUserProfileHandler extends CoreSitePluginsBaseHandle */ type: string; + protected updatingDefer: PromiseDefer; + constructor(name: string, protected title: string, protected plugin: any, protected handlerSchema: any, - protected initResult: any, protected sitePluginsProvider: CoreSitePluginsProvider) { + protected initResult: any, protected sitePluginsProvider: CoreSitePluginsProvider, + protected utils: CoreUtilsProvider) { super(name); this.priority = handlerSchema.priority; @@ -97,4 +101,23 @@ export class CoreSitePluginsUserProfileHandler extends CoreSitePluginsBaseHandle } }; } + + /** + * Set init result. + * + * @param {any} result Result to set. + */ + setInitResult(result: any): void { + this.initResult = result; + + this.updatingDefer.resolve(); + delete this.updatingDefer; + } + + /** + * Mark init being updated. + */ + updatingInit(): void { + this.updatingDefer = this.utils.promiseDefer(); + } } diff --git a/src/core/siteplugins/components/block/block.ts b/src/core/siteplugins/components/block/block.ts index d1e927add8d..c3375a90f57 100644 --- a/src/core/siteplugins/components/block/block.ts +++ b/src/core/siteplugins/components/block/block.ts @@ -27,7 +27,7 @@ import { CoreBlockDelegate } from '@core/block/providers/delegate'; }) export class CoreSitePluginsBlockComponent extends CoreBlockBaseComponent implements OnChanges { @Input() block: any; - @Input() contextLevel: number; + @Input() contextLevel: string; @Input() instanceId: number; @ViewChild(CoreSitePluginsPluginContentComponent) content: CoreSitePluginsPluginContentComponent; @@ -53,7 +53,10 @@ export class CoreSitePluginsBlockComponent extends CoreBlockBaseComponent implem if (handler) { this.component = handler.plugin.component; this.method = handler.handlerSchema.method; - this.args = { }; + this.args = { + contextlevel: this.contextLevel, + instanceid: this.instanceId, + }; this.initResult = handler.initResult; } } diff --git a/src/core/siteplugins/components/components.module.ts b/src/core/siteplugins/components/components.module.ts index ebc7d03b9b9..abba3cd8ca9 100644 --- a/src/core/siteplugins/components/components.module.ts +++ b/src/core/siteplugins/components/components.module.ts @@ -30,12 +30,14 @@ import { CoreSitePluginsAssignFeedbackComponent } from './assign-feedback/assign import { CoreSitePluginsAssignSubmissionComponent } from './assign-submission/assign-submission'; import { CoreSitePluginsWorkshopAssessmentStrategyComponent } from './workshop-assessment-strategy/workshop-assessment-strategy'; import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/block/block'; +import { CoreSitePluginsOnlyTitleBlockComponent } from '@core/siteplugins/components/only-title-block/only-title-block'; @NgModule({ declarations: [ CoreSitePluginsPluginContentComponent, CoreSitePluginsModuleIndexComponent, CoreSitePluginsBlockComponent, + CoreSitePluginsOnlyTitleBlockComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, CoreSitePluginsUserProfileFieldComponent, @@ -59,6 +61,7 @@ import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/bloc CoreSitePluginsPluginContentComponent, CoreSitePluginsModuleIndexComponent, CoreSitePluginsBlockComponent, + CoreSitePluginsOnlyTitleBlockComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, CoreSitePluginsUserProfileFieldComponent, @@ -72,6 +75,7 @@ import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/bloc entryComponents: [ CoreSitePluginsModuleIndexComponent, CoreSitePluginsBlockComponent, + CoreSitePluginsOnlyTitleBlockComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, CoreSitePluginsUserProfileFieldComponent, diff --git a/src/core/siteplugins/components/module-index/module-index.ts b/src/core/siteplugins/components/module-index/module-index.ts index 3e231ecf444..79be3063c5a 100644 --- a/src/core/siteplugins/components/module-index/module-index.ts +++ b/src/core/siteplugins/components/module-index/module-index.ts @@ -169,4 +169,15 @@ export class CoreSitePluginsModuleIndexComponent implements OnInit, OnDestroy, C this.isDestroyed = true; this.statusObserver && this.statusObserver.off(); } + + /** + * Call a certain function on the component instance. + * + * @param {string} name Name of the function to call. + * @param {any[]} params List of params to send to the function. + * @return {any} Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params?: any[]): any { + return this.content.callComponentFunction(name, params); + } } diff --git a/src/core/siteplugins/components/only-title-block/core-siteplugins-only-title-block.html b/src/core/siteplugins/components/only-title-block/core-siteplugins-only-title-block.html new file mode 100644 index 00000000000..287592371e4 --- /dev/null +++ b/src/core/siteplugins/components/only-title-block/core-siteplugins-only-title-block.html @@ -0,0 +1,3 @@ + +

{{ title | translate }}

+
\ No newline at end of file diff --git a/src/core/siteplugins/components/only-title-block/only-title-block.ts b/src/core/siteplugins/components/only-title-block/only-title-block.ts new file mode 100644 index 00000000000..b0ceaa45675 --- /dev/null +++ b/src/core/siteplugins/components/only-title-block/only-title-block.ts @@ -0,0 +1,69 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injector, OnInit, Component, Optional } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreBlockBaseComponent } from '@core/block/classes/base-block-component'; +import { CoreSitePluginsProvider } from '../../providers/siteplugins'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Component to render blocks with only a title and link. + */ +@Component({ + selector: 'core-siteplugins-only-title-block', + templateUrl: 'core-siteplugins-only-title-block.html' +}) +export class CoreSitePluginsOnlyTitleBlockComponent extends CoreBlockBaseComponent implements OnInit { + + constructor(injector: Injector, protected sitePluginsProvider: CoreSitePluginsProvider, + protected blockDelegate: CoreBlockDelegate, private navCtrl: NavController, + @Optional() private svComponent: CoreSplitViewComponent) { + + super(injector, 'CoreSitePluginsOnlyTitleBlockComponent'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.fetchContentDefaultError = 'Error getting ' + this.block.contents.title + ' data.'; + } + + /** + * Go to the block page. + */ + gotoBlock(): void { + const handlerName = this.blockDelegate.getHandlerName(this.block.name); + const handler = this.sitePluginsProvider.getSitePluginHandler(handlerName); + + if (handler) { + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + + navCtrl.push('CoreSitePluginsPluginPage', { + title: this.title, + component: handler.plugin.component, + method: handler.handlerSchema.method, + initResult: handler.initResult, + args: { + contextlevel: this.contextLevel, + instanceid: this.instanceId, + }, + }); + } + } +} diff --git a/src/core/siteplugins/components/plugin-content/core-siteplugins-plugin-content.html b/src/core/siteplugins/components/plugin-content/core-siteplugins-plugin-content.html index c306b73a7a6..06651d0f4ce 100644 --- a/src/core/siteplugins/components/plugin-content/core-siteplugins-plugin-content.html +++ b/src/core/siteplugins/components/plugin-content/core-siteplugins-plugin-content.html @@ -1,3 +1,3 @@ - + diff --git a/src/core/siteplugins/components/plugin-content/plugin-content.ts b/src/core/siteplugins/components/plugin-content/plugin-content.ts index e1fa41f1787..4b635684456 100644 --- a/src/core/siteplugins/components/plugin-content/plugin-content.ts +++ b/src/core/siteplugins/components/plugin-content/plugin-content.ts @@ -176,4 +176,17 @@ export class CoreSitePluginsPluginContentComponent implements OnInit, DoCheck { this.fetchContent(); } + + /** + * Call a certain function on the component instance. + * + * @param {string} name Name of the function to call. + * @param {any[]} params List of params to send to the function. + * @return {any} Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params?: any[]): any { + if (this.compileComponent) { + return ( this.compileComponent).callComponentFunction(name, params); + } + } } diff --git a/src/core/siteplugins/directives/new-content.ts b/src/core/siteplugins/directives/new-content.ts index 8f38ef1c0f5..1158b5dd601 100644 --- a/src/core/siteplugins/directives/new-content.ts +++ b/src/core/siteplugins/directives/new-content.ts @@ -48,7 +48,7 @@ export class CoreSitePluginsNewContentDirective implements OnInit { @Input() args: any; // The params to get the new content. @Input() title: string; // The title to display with the new content. Only if samePage=false. @Input() samePage: boolean | string; // Whether to display the content in same page or open a new one. Defaults to new page. - @Input() useOtherData: any[]; // Whether to include other data in the args. @see CoreSitePluginsProvider.loadOtherDataInArgs. + @Input() useOtherData: any; // Whether to include other data in the args. @see CoreSitePluginsProvider.loadOtherDataInArgs. @Input() form: string; // ID or name to identify a form. The form will be obtained from document.forms. // If supplied and form is found, the form data will be retrieved and sent to the new content. @Input() jsData: any; // JS variables to pass to the new page so they can be used in the template or JS. diff --git a/src/core/siteplugins/pages/module-index/module-index.ts b/src/core/siteplugins/pages/module-index/module-index.ts index de4050eb337..f5829666c12 100644 --- a/src/core/siteplugins/pages/module-index/module-index.ts +++ b/src/core/siteplugins/pages/module-index/module-index.ts @@ -48,4 +48,48 @@ export class CoreSitePluginsModuleIndexPage { refresher.complete(); }); } + + /** + * The page is about to enter and become the active page. + */ + ionViewWillEnter(): void { + this.content.callComponentFunction('ionViewWillEnter'); + } + + /** + * The page has fully entered and is now the active page. This event will fire, whether it was the first load or a cached page. + */ + ionViewDidEnter(): void { + this.content.callComponentFunction('ionViewDidEnter'); + } + + /** + * The page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.content.callComponentFunction('ionViewWillLeave'); + } + + /** + * The page has finished leaving and is no longer the active page. + */ + ionViewDidLeave(): void { + this.content.callComponentFunction('ionViewDidLeave'); + } + + /** + * The page is about to be destroyed and have its elements removed. + */ + ionViewWillUnload(): void { + this.content.callComponentFunction('ionViewWillUnload'); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + return this.content.callComponentFunction('ionViewCanLeave'); + } } diff --git a/src/core/siteplugins/pages/plugin-page/plugin-page.ts b/src/core/siteplugins/pages/plugin-page/plugin-page.ts index 4e18eef73a0..4d3128918bd 100644 --- a/src/core/siteplugins/pages/plugin-page/plugin-page.ts +++ b/src/core/siteplugins/pages/plugin-page/plugin-page.ts @@ -56,4 +56,48 @@ export class CoreSitePluginsPluginPage { refresher.complete(); }); } + + /** + * The page is about to enter and become the active page. + */ + ionViewWillEnter(): void { + this.content.callComponentFunction('ionViewWillEnter'); + } + + /** + * The page has fully entered and is now the active page. This event will fire, whether it was the first load or a cached page. + */ + ionViewDidEnter(): void { + this.content.callComponentFunction('ionViewDidEnter'); + } + + /** + * The page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.content.callComponentFunction('ionViewWillLeave'); + } + + /** + * The page has finished leaving and is no longer the active page. + */ + ionViewDidLeave(): void { + this.content.callComponentFunction('ionViewDidLeave'); + } + + /** + * The page is about to be destroyed and have its elements removed. + */ + ionViewWillUnload(): void { + this.content.callComponentFunction('ionViewWillUnload'); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + return this.content.callComponentFunction('ionViewCanLeave'); + } } diff --git a/src/core/siteplugins/providers/helper.ts b/src/core/siteplugins/providers/helper.ts index b553787286e..b6c33d0fcfe 100644 --- a/src/core/siteplugins/providers/helper.ts +++ b/src/core/siteplugins/providers/helper.ts @@ -30,6 +30,7 @@ import { CoreSitePluginsProvider } from './siteplugins'; import { CoreCompileProvider } from '@core/compile/providers/compile'; import { CoreQuestionProvider } from '@core/question/providers/question'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; // Delegates import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; @@ -78,14 +79,17 @@ import { CoreSitePluginsBlockHandler } from '@core/siteplugins/classes/handlers/ */ @Injectable() export class CoreSitePluginsHelperProvider { + protected HANDLER_DISABLED = 'core_site_plugins_helper_handler_disabled'; + protected logger; + protected courseRestrictHandlers = {}; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private mainMenuDelegate: CoreMainMenuDelegate, private moduleDelegate: CoreCourseModuleDelegate, private userDelegate: CoreUserDelegate, private langProvider: CoreLangProvider, private http: Http, private sitePluginsProvider: CoreSitePluginsProvider, private prefetchDelegate: CoreCourseModulePrefetchDelegate, private compileProvider: CoreCompileProvider, private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider, - private courseOptionsDelegate: CoreCourseOptionsDelegate, eventsProvider: CoreEventsProvider, + private courseOptionsDelegate: CoreCourseOptionsDelegate, private eventsProvider: CoreEventsProvider, private courseFormatDelegate: CoreCourseFormatDelegate, private profileFieldDelegate: CoreUserProfileFieldDelegate, private textUtils: CoreTextUtilsProvider, private filepoolProvider: CoreFilepoolProvider, private settingsDelegate: CoreSettingsDelegate, private questionDelegate: CoreQuestionDelegate, @@ -110,6 +114,8 @@ export class CoreSitePluginsHelperProvider { eventsProvider.trigger(CoreEventsProvider.SITE_PLUGINS_LOADED, {}, data.siteId); }); } + }).catch((e) => { + // Ignore errors here. }).finally(() => { this.sitePluginsProvider.setPluginsFetched(); }); @@ -122,6 +128,13 @@ export class CoreSitePluginsHelperProvider { window.location.reload(); } }); + + // Re-load plugins restricted for courses when the list of user courses changes. + eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, (data) => { + if (data && data.siteId && data.siteId == this.sitesProvider.getCurrentSiteId() && data.added && data.added.length) { + this.reloadCourseRestrictHandlers(); + } + }); } /** @@ -142,6 +155,11 @@ export class CoreSitePluginsHelperProvider { url = this.textUtils.concatenatePaths(site.getURL(), url); } + if (url && handlerSchema.styles.version) { + // Add the version to the URL to prevent getting a cached file. + url += (url.indexOf('?') != -1 ? '&' : '?') + 'version=' + handlerSchema.styles.version; + } + const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), componentId = uniqueName + '#main'; @@ -221,7 +239,9 @@ export class CoreSitePluginsHelperProvider { } // Create a "fake" instance to hold all the libraries. - const instance = {}; + const instance = { + HANDLER_DISABLED: this.HANDLER_DISABLED + }; this.compileProvider.injectLibraries(instance); // Add some data of the WS call result. @@ -233,6 +253,11 @@ export class CoreSitePluginsHelperProvider { // Now execute the javascript using this instance. result.jsResult = this.compileProvider.executeJavascript(instance, result.javascript); + if (result.jsResult == this.HANDLER_DISABLED) { + // The "disabled" field was added in 3.8, this is a workaround for previous versions. + result.disabled = true; + } + return result; }); } @@ -370,6 +395,7 @@ export class CoreSitePluginsHelperProvider { */ loadSitePlugins(plugins: any[]): Promise { const promises = []; + this.courseRestrictHandlers = {}; plugins.forEach((plugin) => { const pluginPromise = this.loadSitePlugin(plugin); @@ -436,6 +462,13 @@ export class CoreSitePluginsHelperProvider { })); return Promise.all(promises).then(() => { + if (result && result.disabled) { + // This handler is disabled for the current user, stop. + this.logger.warn('Handler disabled by init function', plugin, handlerSchema); + + return; + } + if (cssCode) { // Load the styles. this.loadStyles(plugin, handlerName, handlerSchema.styles.url, cssCode, handlerSchema.styles.version, siteId); @@ -630,10 +663,11 @@ export class CoreSitePluginsHelperProvider { string | Promise { const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), - blockName = (handlerSchema.moodlecomponent || plugin.component).replace('block_', ''); + blockName = (handlerSchema.moodlecomponent || plugin.component).replace('block_', ''), + prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); this.blockDelegate.registerHandler( - new CoreSitePluginsBlockHandler(uniqueName, blockName, handlerSchema, initResult)); + new CoreSitePluginsBlockHandler(uniqueName, prefixedTitle, blockName, handlerSchema, initResult)); return uniqueName; } @@ -678,10 +712,21 @@ export class CoreSitePluginsHelperProvider { // Create and register the handler. const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), - prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title); - - this.courseOptionsDelegate.registerHandler(new CoreSitePluginsCourseOptionHandler(uniqueName, prefixedTitle, plugin, - handlerSchema, initResult, this.sitePluginsProvider)); + prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'), + handler = new CoreSitePluginsCourseOptionHandler(uniqueName, prefixedTitle, plugin, + handlerSchema, initResult, this.sitePluginsProvider, this.utils); + + this.courseOptionsDelegate.registerHandler(handler); + + if (initResult && initResult.restrict && initResult.restrict.courses) { + // This handler is restricted to certan courses, store it in the list. + this.courseRestrictHandlers[uniqueName] = { + plugin: plugin, + handlerName: handlerName, + handlerSchema: handlerSchema, + handler: handler + }; + } return uniqueName; } @@ -707,7 +752,7 @@ export class CoreSitePluginsHelperProvider { // Create and register the handler. const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), - prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title); + prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); this.mainMenuDelegate.registerHandler( new CoreSitePluginsMainMenuHandler(uniqueName, prefixedTitle, plugin, handlerSchema, initResult)); @@ -736,7 +781,7 @@ export class CoreSitePluginsHelperProvider { // Create and register the handler. const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), - prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title), + prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'), processorName = (handlerSchema.moodlecomponent || plugin.component).replace('message_', ''); this.messageOutputDelegate.registerHandler(new CoreSitePluginsMessageOutputHandler(uniqueName, processorName, @@ -855,7 +900,7 @@ export class CoreSitePluginsHelperProvider { // Create and register the handler. const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), - prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title); + prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); this.settingsDelegate.registerHandler( new CoreSitePluginsSettingsHandler(uniqueName, prefixedTitle, plugin, handlerSchema, initResult)); @@ -884,10 +929,21 @@ export class CoreSitePluginsHelperProvider { // Create and register the handler. const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), - prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title); - - this.userDelegate.registerHandler(new CoreSitePluginsUserProfileHandler(uniqueName, prefixedTitle, plugin, handlerSchema, - initResult, this.sitePluginsProvider)); + prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'), + handler = new CoreSitePluginsUserProfileHandler(uniqueName, prefixedTitle, plugin, handlerSchema, + initResult, this.sitePluginsProvider, this.utils); + + this.userDelegate.registerHandler(handler); + + if (initResult && initResult.restrict && initResult.restrict.courses) { + // This handler is restricted to certan courses, store it in the list. + this.courseRestrictHandlers[uniqueName] = { + plugin: plugin, + handlerName: handlerName, + handlerSchema: handlerSchema, + handler: handler + }; + } return uniqueName; } @@ -930,4 +986,40 @@ export class CoreSitePluginsHelperProvider { return new CoreSitePluginsWorkshopAssessmentStrategyHandler(uniqueName, strategyName); }); } + + /** + * Reload the handlers that are restricted to certain courses. + * + * @return {Promise} Promise resolved when done. + */ + protected reloadCourseRestrictHandlers(): Promise { + if (!Object.keys(this.courseRestrictHandlers).length) { + // No course restrict handlers, nothing to do. + return Promise.resolve(); + } + + const promises = []; + + for (const name in this.courseRestrictHandlers) { + const data = this.courseRestrictHandlers[name]; + + if (!data.handler || !data.handler.setInitResult) { + // No handler or it doesn't implement a required function, ignore it. + continue; + } + + // Mark the handler as being updated. + data.handler.updatingInit && data.handler.updatingInit(); + + promises.push(this.executeHandlerInit(data.plugin, data.handlerSchema).then((initResult) => { + data.handler.setInitResult(initResult); + }).catch((error) => { + this.logger.error('Error reloading course restrict handler', error, data.plugin); + })); + } + + return Promise.all(promises).then(() => { + this.eventsProvider.trigger(CoreEventsProvider.SITE_PLUGINS_COURSE_RESTRICT_UPDATED, {}); + }); + } } diff --git a/src/core/siteplugins/providers/siteplugins.ts b/src/core/siteplugins/providers/siteplugins.ts index 8739e472248..fe20b3115e2 100644 --- a/src/core/siteplugins/providers/siteplugins.ts +++ b/src/core/siteplugins/providers/siteplugins.ts @@ -421,15 +421,15 @@ export class CoreSitePluginsProvider { /** * Load other data into args as determined by useOtherData list. * If useOtherData is undefined, it won't add any data. - * If useOtherData is defined but empty (null, false or empty string) it will copy all the data from otherData to args. * If useOtherData is an array, it will only copy the properties whose names are in the array. + * If useOtherData is any other value, it will copy all the data from otherData to args. * * @param {any} args The current args. * @param {any} otherData All the other data. - * @param {any[]} useOtherData Names of the attributes to include. + * @param {any} useOtherData Names of the attributes to include. * @return {any} New args. */ - loadOtherDataInArgs(args: any, otherData: any, useOtherData: any[]): any { + loadOtherDataInArgs(args: any, otherData: any, useOtherData: any): any { if (!args) { args = {}; } else { @@ -441,15 +441,27 @@ export class CoreSitePluginsProvider { if (typeof useOtherData == 'undefined') { // No need to add other data, return args as they are. return args; - } else if (!useOtherData) { - // Use other data is defined but empty. Add all the data to args. - for (const name in otherData) { - args[name] = otherData[name]; - } - } else { + } else if (Array.isArray(useOtherData)) { + // Include only the properties specified in the array. for (const i in useOtherData) { const name = useOtherData[i]; - args[name] = otherData[name]; + + if (typeof otherData[name] == 'object' && otherData[name] !== null) { + // Stringify objects. + args[name] = JSON.stringify(otherData[name]); + } else { + args[name] = otherData[name]; + } + } + } else { + // Add all the data to args. + for (const name in otherData) { + if (typeof otherData[name] == 'object' && otherData[name] !== null) { + // Stringify objects. + args[name] = JSON.stringify(otherData[name]); + } else { + args[name] = otherData[name]; + } } } diff --git a/src/core/tag/components/components.module.ts b/src/core/tag/components/components.module.ts new file mode 100644 index 00000000000..8960002cdc0 --- /dev/null +++ b/src/core/tag/components/components.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreTagFeedComponent } from './feed/feed'; +import { CoreTagListComponent } from './list/list'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreTagFeedComponent, + CoreTagListComponent + ], + imports: [ + CommonModule, + IonicModule, + CoreDirectivesModule, + TranslateModule.forChild() + ], + providers: [ + ], + exports: [ + CoreTagFeedComponent, + CoreTagListComponent + ], + entryComponents: [ + CoreTagFeedComponent + ] +}) +export class CoreTagComponentsModule {} diff --git a/src/core/tag/components/feed/core-tag-feed.html b/src/core/tag/components/feed/core-tag-feed.html new file mode 100644 index 00000000000..fe4a02e21a2 --- /dev/null +++ b/src/core/tag/components/feed/core-tag-feed.html @@ -0,0 +1,8 @@ +
+ + + + +

{{ item.heading }}

+

{{ text }}

+
diff --git a/src/core/tag/components/feed/feed.ts b/src/core/tag/components/feed/feed.ts new file mode 100644 index 00000000000..5c554f7c39d --- /dev/null +++ b/src/core/tag/components/feed/feed.ts @@ -0,0 +1,26 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; + +/** + * Component to render a tag area that uses the "core_tag/tagfeed" web template. + */ +@Component({ + selector: 'core-tag-feed', + templateUrl: 'core-tag-feed.html' +}) +export class CoreTagFeedComponent { + @Input() items: any[]; // Area items to render. +} diff --git a/src/core/tag/components/list/core-tag-list.html b/src/core/tag/components/list/core-tag-list.html new file mode 100644 index 00000000000..7e6372e20eb --- /dev/null +++ b/src/core/tag/components/list/core-tag-list.html @@ -0,0 +1,3 @@ + + {{ tag.rawname }} + diff --git a/src/core/tag/components/list/list.scss b/src/core/tag/components/list/list.scss new file mode 100644 index 00000000000..569d645d627 --- /dev/null +++ b/src/core/tag/components/list/list.scss @@ -0,0 +1,7 @@ +ion-app.app-root core-tag-list { + line-height: 1.6; + + ion-badge { + cursor: pointer; + } +} diff --git a/src/core/tag/components/list/list.ts b/src/core/tag/components/list/list.ts new file mode 100644 index 00000000000..6abbf3d7fad --- /dev/null +++ b/src/core/tag/components/list/list.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Optional } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreTagItem } from '@core/tag/providers/tag'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Component that displays the list of tags of an item. + */ +@Component({ + selector: 'core-tag-list', + templateUrl: 'core-tag-list.html' +}) +export class CoreTagListComponent { + @Input() tags: CoreTagItem[]; + + constructor(private navCtrl: NavController, @Optional() private svComponent: CoreSplitViewComponent) {} + + /** + * Go to tag index page. + */ + openTag(tag: CoreTagItem): void { + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + const params = { + tagId: tag.id, + tagName: tag.rawname, + collectionId: tag.tagcollid, + fromContextId: tag.taginstancecontextid + }; + navCtrl.push('CoreTagIndexPage', params); + } +} diff --git a/src/core/tag/lang/en.json b/src/core/tag/lang/en.json new file mode 100644 index 00000000000..c23afc5e934 --- /dev/null +++ b/src/core/tag/lang/en.json @@ -0,0 +1,16 @@ +{ + "defautltagcoll": "Default collection", + "errorareanotsupported": "This tag area is not supported by the app.", + "inalltagcoll": "Everywhere", + "itemstaggedwith": "{{$a.tagarea}} tagged with \"{{$a.tag}}\"", + "notagsfound": "No tags matching \"{{$a}}\" found", + "searchtags": "Search tags", + "showingfirsttags": "Showing {{$a}} most popular tags", + "tag": "Tag", + "tagarea_course": "Courses", + "tagarea_course_modules": "Activities and resources", + "tagarea_post": "Blog posts", + "tagarea_user": "User interests", + "tags": "Tags", + "warningareasnotsupported": "Some of the tag areas are not displayed because they are not supported by the app." +} diff --git a/src/core/tag/pages/index-area/index-area.html b/src/core/tag/pages/index-area/index-area.html new file mode 100644 index 00000000000..8a43d2d51b5 --- /dev/null +++ b/src/core/tag/pages/index-area/index-area.html @@ -0,0 +1,16 @@ + + + {{ 'core.tag.itemstaggedwith' | translate: { $a: {tagarea: areaNameKey | translate, tag: tagName} } }} + + + + + + + + + + + + + diff --git a/src/core/tag/pages/index-area/index-area.module.ts b/src/core/tag/pages/index-area/index-area.module.ts new file mode 100644 index 00000000000..87a49cd7fc3 --- /dev/null +++ b/src/core/tag/pages/index-area/index-area.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreTagIndexAreaPage } from './index-area'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreTagIndexAreaPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreTagIndexAreaPage), + TranslateModule.forChild() + ], +}) +export class CoreTagIndexAreaPageModule {} diff --git a/src/core/tag/pages/index-area/index-area.ts b/src/core/tag/pages/index-area/index-area.ts new file mode 100644 index 00000000000..9f71f053287 --- /dev/null +++ b/src/core/tag/pages/index-area/index-area.ts @@ -0,0 +1,150 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTagProvider } from '@core/tag/providers/tag'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; + +/** + * Page that displays the tag index area. + */ +@IonicPage({ segment: 'core-tag-index-area' }) +@Component({ + selector: 'page-core-tag-index-area', + templateUrl: 'index-area.html', +}) +export class CoreTagIndexAreaPage { + tagId: number; + tagName: string; + collectionId: number; + areaId: number; + fromContextId: number; + contextId: number; + recursive: boolean; + areaNameKey: string; + loaded = false; + componentName: string; + itemType: string; + items = []; + nextPage = 0; + canLoadMore = false; + areaComponent: any; + loadMoreError = false; + + constructor(navParams: NavParams, private injector: Injector, private translate: TranslateService, + private tagProvider: CoreTagProvider, private domUtils: CoreDomUtilsProvider, + private tagAreaDelegate: CoreTagAreaDelegate) { + this.tagId = navParams.get('tagId'); + this.tagName = navParams.get('tagName'); + this.collectionId = navParams.get('collectionId'); + this.areaId = navParams.get('areaId'); + this.fromContextId = navParams.get('fromContextId'); + this.contextId = navParams.get('contextId'); + this.recursive = navParams.get('recursive'); + this.areaNameKey = navParams.get('areaNameKey'); + + // Pass the the following parameters to avoid fetching the first page. + this.componentName = navParams.get('componentName'); + this.itemType = navParams.get('itemType'); + this.items = navParams.get('items') || []; + this.nextPage = navParams.get('nextPage') || 0; + this.canLoadMore = !!navParams.get('canLoadMore'); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + let promise: Promise; + if (!this.componentName || !this.itemType || !this.items.length || this.nextPage == 0) { + promise = this.fetchData(true); + } else { + promise = Promise.resolve(); + } + + promise.then(() => { + return this.tagAreaDelegate.getComponent(this.componentName, this.itemType, this.injector).then((component) => { + this.areaComponent = component; + }); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch next page of the tag index area. + * + * @param {boolean} [refresh=false] Whether to refresh the data or fetch a new page. + * @return {Promise} Resolved when done. + */ + fetchData(refresh: boolean = false): Promise { + this.loadMoreError = false; + const page = refresh ? 0 : this.nextPage; + + return this.tagProvider.getTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive, page).then((areas) => { + const area = areas[0]; + + return this.tagAreaDelegate.parseContent(area.component, area.itemtype, area.content).then((items) => { + if (!items || !items.length) { + // Tag area not supported. + return Promise.reject(this.translate.instant('core.tag.errorareanotsupported')); + } + + if (page == 0) { + this.items = items; + } else { + this.items.push(...items); + } + this.componentName = area.component; + this.itemType = area.itemtype; + this.areaNameKey = this.tagAreaDelegate.getDisplayNameKey(area.component, area.itemtype); + this.canLoadMore = !!area.nextpageurl; + this.nextPage = page + 1; + }); + }).catch((error) => { + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + this.domUtils.showErrorModalDefault(error, 'Error loading tag index'); + }); + } + + /** + * Load more items. + * + * @param {any} infiniteComplete Infinite scroll complete function. + * @return {Promise} Resolved when done. + */ + loadMore(infiniteComplete: any): Promise { + return this.fetchData().finally(() => { + infiniteComplete(); + }); + } + + /** + * Refresh data. + * + * @param {any} refresher Refresher. + */ + refreshData(refresher: any): void { + this.tagProvider.invalidateTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive).finally(() => { + this.fetchData(true).finally(() => { + refresher.complete(); + }); + }); + } +} diff --git a/src/core/tag/pages/index/index.html b/src/core/tag/pages/index/index.html new file mode 100644 index 00000000000..5174fcd7cab --- /dev/null +++ b/src/core/tag/pages/index/index.html @@ -0,0 +1,24 @@ + + + {{ 'core.tag.tag' | translate }}: {{ tagName }} + + + + + + + + + + + + {{ 'core.tag.warningareasnotsupported' | translate }} + + +

{{ area.nameKey | translate }}

+ {{ area.badge }} +
+
+
+
+
diff --git a/src/core/tag/pages/index/index.module.ts b/src/core/tag/pages/index/index.module.ts new file mode 100644 index 00000000000..bb3cd138d18 --- /dev/null +++ b/src/core/tag/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreTagIndexPage } from './index'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreTagIndexPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreTagIndexPage), + TranslateModule.forChild() + ], +}) +export class CoreTagIndexPageModule {} diff --git a/src/core/tag/pages/index/index.ts b/src/core/tag/pages/index/index.ts new file mode 100644 index 00000000000..9185bae0fc1 --- /dev/null +++ b/src/core/tag/pages/index/index.ts @@ -0,0 +1,154 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreTagProvider } from '@core/tag/providers/tag'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; + +/** + * Page that displays the tag index. + */ +@IonicPage({ segment: 'core-tag-index' }) +@Component({ + selector: 'page-core-tag-index', + templateUrl: 'index.html', +}) +export class CoreTagIndexPage { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + tagId: number; + tagName: string; + collectionId: number; + areaId: number; + fromContextId: number; + contextId: number; + recursive: boolean; + loaded = false; + areas: Array<{ + id: number, + componentName: string, + itemType: string, + nameKey: string, + items: any[], + canLoadMore: boolean, + badge: string + }>; + selectedAreaId: number; + hasUnsupportedAreas = false; + + constructor(navParams: NavParams, private tagProvider: CoreTagProvider, private domUtils: CoreDomUtilsProvider, + private tagAreaDelegate: CoreTagAreaDelegate) { + this.tagId = navParams.get('tagId') || 0; + this.tagName = navParams.get('tagName') || ''; + this.collectionId = navParams.get('collectionId'); + this.areaId = navParams.get('areaId') || 0; + this.fromContextId = navParams.get('fromContextId') || 0; + this.contextId = navParams.get('contextId') || 0; + this.recursive = navParams.get('recursive') || true; + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData().then(() => { + if (this.splitviewCtrl.isOn() && this.areas && this.areas.length > 0) { + const area = this.areas.find((area) => area.id == this.areaId); + this.openArea(area || this.areas[0]); + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch first page of tag index per area. + * + * @return {Promise} Resolved when done. + */ + fetchData(): Promise { + return this.tagProvider.getTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive, 0).then((areas) => { + this.areas = []; + this.hasUnsupportedAreas = false; + + return Promise.all(areas.map((area) => { + return this.tagAreaDelegate.parseContent(area.component, area.itemtype, area.content).then((items) => { + if (!items || !items.length) { + // Tag area not supported, skip. + this.hasUnsupportedAreas = true; + + return null; + } + + return { + id: area.ta, + componentName: area.component, + itemType: area.itemtype, + nameKey: this.tagAreaDelegate.getDisplayNameKey(area.component, area.itemtype), + items, + canLoadMore: !!area.nextpageurl, + badge: items && items.length ? items.length + (area.nextpageurl ? '+' : '') : '', + }; + }); + })).then((areas) => { + this.areas = areas.filter((area) => area != null); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading tag index'); + }); + } + + /** + * Refresh data. + * + * @param {any} refresher Refresher. + */ + refreshData(refresher: any): void { + this.tagProvider.invalidateTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive).finally(() => { + this.fetchData().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Navigate to an index area. + * + * @param {any} area Area. + */ + openArea(area: any): void { + this.selectedAreaId = area.id; + const params = { + tagId: this.tagId, + tagName: this.tagName, + collectionId: this.collectionId, + areaId: area.id, + fromContextId: this.fromContextId, + contextId: this.contextId, + recursive: this.recursive, + areaNameKey: area.nameKey, + componentName: area.component, + itemType: area.itemType, + items: area.items.slice(), + canLoadMore: area.canLoadMore, + nextPage: 1 + }; + this.splitviewCtrl.push('CoreTagIndexAreaPage', params); + } +} diff --git a/src/core/tag/pages/search/search.html b/src/core/tag/pages/search/search.html new file mode 100644 index 00000000000..5635d09d934 --- /dev/null +++ b/src/core/tag/pages/search/search.html @@ -0,0 +1,37 @@ + + + {{ 'core.tag.searchtags' | translate }} + + + + + + + + + + + + + + {{ 'core.tag.inalltagcoll' | translate }} + {{ collection.name }} + + + + + + + + +
+ + {{ tag.name }} + +
+

+ {{ 'core.tag.showingfirsttags' | translate: {$a: cloud.tags.length} }} +

+
+
+
diff --git a/src/core/tag/pages/search/search.module.ts b/src/core/tag/pages/search/search.module.ts new file mode 100644 index 00000000000..29776ce6810 --- /dev/null +++ b/src/core/tag/pages/search/search.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreTagSearchPage } from './search'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreTagSearchPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreTagSearchPage), + TranslateModule.forChild() + ], +}) +export class CoreTagSerchPageModule {} diff --git a/src/core/tag/pages/search/search.scss b/src/core/tag/pages/search/search.scss new file mode 100644 index 00000000000..cd71734457c --- /dev/null +++ b/src/core/tag/pages/search/search.scss @@ -0,0 +1,95 @@ +ion-app.app-root page-core-tag-search { + core-search-box ion-card { + width: 100% !important; + margin: 0 !important; + } + + .core-tag-cloud ion-badge { + margin: 8px; + cursor: pointer; + + .size20 { + font-size: 3.4rem; + } + + .size19 { + font-size: 3.3rem; + } + + .size18 { + font-size: 3.2rem; + } + + .size17 { + font-size: 3.1rem; + } + + .size16 { + font-size: 3rem; + } + + .size15 { + font-size: 2.9rem; + } + + .size14 { + font-size: 2.8rem; + } + + .size13 { + font-size: 2.7rem; + } + + .size12 { + font-size: 2.6rem; + } + + .size11 { + font-size: 2.5rem; + } + + .size10 { + font-size: 2.4rem; + } + + .size9 { + font-size: 2.3rem; + } + + .size8 { + font-size: 2.2rem; + } + + .size7 { + font-size: 2.1rem; + } + + .size6 { + font-size: 2rem; + } + + .size5 { + font-size: 1.9rem; + } + + .size4 { + font-size: 1.8rem; + } + + .size3 { + font-size: 1.7rem; + } + + .size2 { + font-size: 1.6rem; + } + + .size1 { + font-size: 1.5rem; + } + + .size0 { + font-size: 1.4rem; + } + } +} diff --git a/src/core/tag/pages/search/search.ts b/src/core/tag/pages/search/search.ts new file mode 100644 index 00000000000..13f09bb4cec --- /dev/null +++ b/src/core/tag/pages/search/search.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, NavParams, NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreTagProvider, CoreTagCloud, CoreTagCollection, CoreTagCloudTag } from '@core/tag/providers/tag'; + +/** + * Page that displays most used tags and allows searching. + */ +@IonicPage({ segment: 'core-tag-search' }) +@Component({ + selector: 'page-core-tag-search', + templateUrl: 'search.html', +}) +export class CoreTagSearchPage { + collectionId: number; + query: string; + collections: CoreTagCollection[] = []; + cloud: CoreTagCloud; + loaded = false; + searching = false; + + constructor(private navCtrl: NavController, navParams: NavParams, private appProvider: CoreAppProvider, + private translate: TranslateService, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, + private textUtils: CoreTextUtilsProvider, private contentLinksHelper: CoreContentLinksHelperProvider, + private tagProvider: CoreTagProvider) { + this.collectionId = navParams.get('collectionId') || 0; + this.query = navParams.get('query') || ''; + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + fetchData(): Promise { + return Promise.all([ + this.fetchCollections(), + this.fetchTags() + ]).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading tags.'); + }); + } + + /** + * Fetch tag collections. + * + * @return {Promise} Resolved when done. + */ + fetchCollections(): Promise { + return this.tagProvider.getTagCollections().then((collections) => { + collections.forEach((collection) => { + if (!collection.name && collection.isdefault) { + collection.name = this.translate.instant('core.tag.defautltagcoll'); + } + }); + this.collections = collections; + }); + } + + /** + * Fetch tags. + * + * @return {Promise} Resolved when done. + */ + fetchTags(): Promise { + return this.tagProvider.getTagCloud(this.collectionId, undefined, undefined, this.query).then((cloud) => { + this.cloud = cloud; + }); + } + + /** + * Go to tag index page. + */ + openTag(tag: CoreTagCloudTag): void { + const url = this.textUtils.decodeURI(tag.viewurl); + this.contentLinksHelper.handleLink(url, undefined, this.navCtrl); + } + + /** + * Refresh data. + * + * @param {any} refresher Refresher. + */ + refreshData(refresher: any): void { + this.utils.allPromises([ + this.tagProvider.invalidateTagCollections(), + this.tagProvider.invalidateTagCloud(this.collectionId, undefined, undefined, this.query), + ]).finally(() => { + return this.fetchData().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Search tags. + * + * @param {string} query Search query. + * @return {Promise} Resolved when done. + */ + searchTags(query: string): Promise { + this.searching = true; + this.query = query; + this.appProvider.closeKeyboard(); + + return this.fetchTags().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading tags.'); + }).finally(() => { + this.searching = false; + }); + } +} diff --git a/src/core/tag/providers/area-delegate.ts b/src/core/tag/providers/area-delegate.ts new file mode 100644 index 00000000000..2b0e2ffb861 --- /dev/null +++ b/src/core/tag/providers/area-delegate.ts @@ -0,0 +1,98 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; + +/** + * Interface that all tag area handlers must implement. + */ +export interface CoreTagAreaHandler extends CoreDelegateHandler { + /** + * Component and item type separated by a slash. E.g. 'core/course_modules'. + * @type {string} + */ + type: string; + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise; + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise; +} + +/** + * Delegate to register tag area handlers. + */ +@Injectable() +export class CoreTagAreaDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider) { + super('CoreTagAreaDelegate', logger, sitesProvider, eventsProvider); + } + + /** + * Returns the display name string for this area. + * + * @param {string} component Component name. + * @param {string} itemType Item type. + * @return {string} String key. + */ + getDisplayNameKey(component: string, itemType: string): string { + return (component == 'core' ? 'core.tag' : 'addon.' + component) + '.tagarea_' + itemType; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} component Component name. + * @param {string} itemType Item type. + * @param {string} content Rendered content. + * @return {Promise} Promise resolved with the area items, or undefined if not found. + */ + parseContent(component: string, itemType: string, content: string): Promise { + const type = component + '/' + itemType; + + return Promise.resolve(this.executeFunctionOnEnabled(type, 'parseContent', [content])); + } + + /** + * Get the component to use to display an area item. + * + * @param {string} component Component name. + * @param {string} itemType Item type. + * @param {Injector} injector Injector. + * @return {Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(component: string, itemType: string, injector: Injector): Promise { + const type = component + '/' + itemType; + + return Promise.resolve(this.executeFunctionOnEnabled(type, 'getComponent', [injector])); + } +} diff --git a/src/core/tag/providers/helper.ts b/src/core/tag/providers/helper.ts new file mode 100644 index 00000000000..38c097b79cf --- /dev/null +++ b/src/core/tag/providers/helper.ts @@ -0,0 +1,81 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Service with helper functions for tags. + */ +@Injectable() +export class CoreTagHelperProvider { + + constructor(protected domUtils: CoreDomUtilsProvider) {} + + /** + * Parses the rendered content of the "core_tag/tagfeed" web template and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]} Area items. + */ + parseFeedContent(content: string): any[] { + const items = []; + const element = this.domUtils.convertToElement(content); + + Array.from(element.querySelectorAll('ul.tag_feed > li.media')).forEach((itemElement) => { + const item: any = { details: [] }; + + Array.from(itemElement.querySelectorAll('div.media-body > div')).forEach((div: HTMLElement) => { + if (div.classList.contains('media-heading')) { + item.heading = div.innerText.trim(); + const link = div.querySelector('a'); + if (link) { + item.url = link.getAttribute('href'); + } + } else { + // Separate details by lines. + const lines = ['']; + Array.from(div.childNodes).forEach((childNode: Node) => { + if (childNode.nodeType == Node.TEXT_NODE) { + lines[lines.length - 1] += childNode.textContent; + } else if (childNode.nodeType == Node.ELEMENT_NODE) { + const childElement = childNode as HTMLElement; + if (childElement.tagName == 'BR') { + lines.push(''); + } else { + lines[lines.length - 1] += childElement.innerText; + } + } + }); + item.details.push(...lines.map((line) => line.trim()).filter((line) => line != '')); + } + }); + + const image = itemElement.querySelector('div.itemimage img'); + if (image) { + if (image.classList.contains('userpicture')) { + item.avatarUrl = image.getAttribute('src'); + } else { + item.iconUrl = image.getAttribute('src'); + } + } + + if (item.heading && item.url) { + items.push(item); + } + }); + + return items; + } +} diff --git a/src/core/tag/providers/index-link-handler.ts b/src/core/tag/providers/index-link-handler.ts new file mode 100644 index 00000000000..c8e1d7c4977 --- /dev/null +++ b/src/core/tag/providers/index-link-handler.ts @@ -0,0 +1,81 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreTagProvider } from './tag'; + +/** + * Handler to treat links to tag index. + */ +@Injectable() +export class CoreTagIndexLinkHandler extends CoreContentLinksHandlerBase { + name = 'CoreTagIndexLinkHandler'; + pattern = /\/tag\/index\.php/; + + constructor(private tagProvider: CoreTagProvider, private linkHelper: CoreContentLinksHelperProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @param {any} [data] Extra data to handle the URL. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number, data?: any): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const pageParams = { + tagId: parseInt(params.id, 10) || 0, + tagName: params.tag || '', + collectionId: parseInt(params.tc, 10) || 0, + areaId: parseInt(params.ta, 10) || 0, + fromContextId: parseInt(params.from, 10) || 0, + contextId: parseInt(params.ctx, 10) || 0, + recursive: parseInt(params.rec, 10) || 1 + }; + + if (!pageParams.tagId && (!pageParams.tagName || !pageParams.collectionId)) { + this.linkHelper.goInSite(navCtrl, 'CoreTagSearchPage', {}, siteId); + } else if (pageParams.areaId) { + this.linkHelper.goInSite(navCtrl, 'CoreTagIndexAreaPage', pageParams, siteId); + } else { + this.linkHelper.goInSite(navCtrl, 'CoreTagIndexPage', pageParams, siteId); + } + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.tagProvider.areTagsAvailable(siteId); + } +} diff --git a/src/core/tag/providers/mainmenu-handler.ts b/src/core/tag/providers/mainmenu-handler.ts new file mode 100644 index 00000000000..8e676acc1d5 --- /dev/null +++ b/src/core/tag/providers/mainmenu-handler.ts @@ -0,0 +1,59 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreTagProvider } from './tag'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@core/mainmenu/providers/delegate'; +import { CoreUtilsProvider } from '@providers/utils/utils'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable() +export class CoreTagMainMenuHandler implements CoreMainMenuHandler { + name = 'CoreTag'; + priority = 300; + + constructor(private tagProvider: CoreTagProvider, private utils: CoreUtilsProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean | Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.tagProvider.areTagsAvailable().then((available) => { + if (!available) { + return false; + } + + // The only way to check whether tags are enabled on web is to perform a WS call. + return this.utils.promiseWorks(this.tagProvider.getTagCollections()); + }); + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreMainMenuHandlerData} Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'pricetags', + title: 'core.tag.tags', + page: 'CoreTagSearchPage', + class: 'core-tag-search-handler' + }; + } +} diff --git a/src/core/tag/providers/search-link-handler.ts b/src/core/tag/providers/search-link-handler.ts new file mode 100644 index 00000000000..68ea7cb9696 --- /dev/null +++ b/src/core/tag/providers/search-link-handler.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreTagProvider } from './tag'; + +/** + * Handler to treat links to tag search. + */ +@Injectable() +export class CoreTagSearchLinkHandler extends CoreContentLinksHandlerBase { + name = 'CoreTagSearchLinkHandler'; + pattern = /\/tag\/search\.php/; + + constructor(private tagProvider: CoreTagProvider, private linkHelper: CoreContentLinksHelperProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @param {any} [data] Extra data to handle the URL. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number, data?: any): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const pageParams = { + collectionId: parseInt(params.tc, 10) || 0, + query: params.query || '', + }; + + this.linkHelper.goInSite(navCtrl, 'CoreTagSearchPage', pageParams, siteId); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.tagProvider.areTagsAvailable(siteId); + } +} diff --git a/src/core/tag/providers/tag.ts b/src/core/tag/providers/tag.ts new file mode 100644 index 00000000000..3a377cba96a --- /dev/null +++ b/src/core/tag/providers/tag.ts @@ -0,0 +1,345 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; + +/** + * Structure of a tag cloud returned by WS. + */ +export interface CoreTagCloud { + tags: CoreTagCloudTag[]; + tagscount: number; + totalcount: number; +} + +/** + * Structure of a tag cloud tag returned by WS. + */ +export interface CoreTagCloudTag { + name: string; + viewurl: string; + flag: boolean; + isstandard: boolean; + count: number; + size: number; +} + +/** + * Structure of a tag collection returned by WS. + */ +export interface CoreTagCollection { + id: number; + name: string; + isdefault: boolean; + component: string; + sortoder: number; + searchable: boolean; + customurl: string; +} + +/** + * Structure of a tag index returned by WS. + */ +export interface CoreTagIndex { + tagid: number; + ta: number; + component: string; + itemtype: string; + nextpageurl: string; + prevpageurl: string; + exclusiveurl: string; + exclusivetext: string; + title: string; + content: string; + hascontent: number; + anchor: string; +} + +/** + * Structure of a tag item returned by WS. + */ +export interface CoreTagItem { + id: number; + name: string; + rawname: string; + isstandard: boolean; + tagcollid: number; + taginstanceid: number; + taginstancecontextid: number; + itemid: number; + ordering: number; + flag: number; +} + +/** + * Service to handle tags. + */ +@Injectable() +export class CoreTagProvider { + + static SEARCH_LIMIT = 150; + + protected ROOT_CACHE_KEY = 'CoreTag:'; + + constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService) {} + + /** + * Check whether tags are available in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if available, resolved with false otherwise. + * @since 3.7 + */ + areTagsAvailable(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.areTagsAvailableInSite(site); + }); + } + + /** + * Check whether tags are available in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} True if available. + */ + areTagsAvailableInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_tag_get_tagindex_per_area') && + site.wsAvailable('core_tag_get_tag_cloud') && + site.wsAvailable('core_tag_get_tag_collections') && + !site.isFeatureDisabled('NoDelegate_CoreTag'); + } + + /** + * Fetch the tag cloud. + * + * @param {number} [collectionId=0] Tag collection ID. + * @param {boolean} [isStandard=false] Whether to return only standard tags. + * @param {string} [sort='name'] Sort order for display (id, name, rawname, count, flag, isstandard, tagcollid). + * @param {string} [search=''] Search string. + * @param {number} [fromContextId=0] Context ID where this tag cloud is displayed. + * @param {number} [contextId=0] Only retrieve tag instances in this context. + * @param {boolean} [recursive=true] Retrieve tag instances in the context and its children. + * @param {number} [limit] Maximum number of tags to retrieve. Defaults to SEARCH_LIMIT. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the tag cloud. + * @since 3.7 + */ + getTagCloud(collectionId: number = 0, isStandard: boolean = false, sort: string = 'name', search: string = '', + fromContextId: number = 0, contextId: number = 0, recursive: boolean = true, limit?: number, siteId?: string): + Promise { + limit = limit || CoreTagProvider.SEARCH_LIMIT; + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + tagcollid: collectionId, + isstandard: isStandard, + limit: limit, + sort: sort, + search: search, + fromctx: fromContextId, + ctx: contextId, + rec: recursive + }; + const preSets: CoreSiteWSPreSets = { + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + cacheKey: this.getTagCloudKey(collectionId, isStandard, sort, search, fromContextId, contextId, recursive), + getFromCache: search != '' // Try to get updated data when searching. + }; + + return site.read('core_tag_get_tag_cloud', params, preSets); + }); + } + + /** + * Fetch the tag collections. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the tag collections. + * @since 3.7 + */ + getTagCollections(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const preSets: CoreSiteWSPreSets = { + updateFrequency: CoreSite.FREQUENCY_RARELY, + cacheKey: this.getTagCollectionsKey() + }; + + return site.read('core_tag_get_tag_collections', null, preSets).then((response) => { + if (!response || !response.collections) { + return Promise.reject(null); + } + + return response.collections; + }); + }); + } + + /** + * Fetch the tag index. + * + * @param {number} [id=0] Tag ID. + * @param {string} [name=''] Tag name. + * @param {number} [collectionId=0] Tag collection ID. + * @param {number} [areaId=0] Tag area ID. + * @param {number} [fromContextId=0] Context ID where the link was displayed. + * @param {number} [contextId=0] Context ID where to search for items. + * @param {boolean} [recursive=true] Search in the context and its children. + * @param {number} [page=0] Page number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the tag index per area. + * @since 3.7 + */ + getTagIndexPerArea(id: number, name: string = '', collectionId: number = 0, areaId: number = 0, fromContextId: number = 0, + contextId: number = 0, recursive: boolean = true, page: number = 0, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + tagindex: { + id: id, + tag: name, + tc: collectionId, + ta: areaId, + excl: true, + from: fromContextId, + ctx: contextId, + rec: recursive, + page: page + }, + }; + const preSets: CoreSiteWSPreSets = { + updateFrequency: CoreSite.FREQUENCY_OFTEN, + cacheKey: this.getTagIndexPerAreaKey(id, name, collectionId, areaId, fromContextId, contextId, recursive) + }; + + return site.read('core_tag_get_tagindex_per_area', params, preSets).catch((error) => { + // Workaround for WS not passing parameter to error string. + if (error && error.errorcode == 'notagsfound') { + error.message = this.translate.instant('core.tag.notagsfound', {$a: name || id || ''}); + } + + return Promise.reject(error); + }).then((response) => { + if (!response || !response.length) { + return Promise.reject(null); + } + + return response; + }); + }); + } + + /** + * Invalidate tag cloud. + * + * @param {number} [collectionId=0] Tag collection ID. + * @param {boolean} [isStandard=false] Whether to return only standard tags. + * @param {string} [sort='name'] Sort order for display (id, name, rawname, count, flag, isstandard, tagcollid). + * @param {string} [search=''] Search string. + * @param {number} [fromContextId=0] Context ID where this tag cloud is displayed. + * @param {number} [contextId=0] Only retrieve tag instances in this context. + * @param {boolean} [recursive=true] Retrieve tag instances in the context and its children. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTagCloud(collectionId: number = 0, isStandard: boolean = false, sort: string = 'name', search: string = '', + fromContextId: number = 0, contextId: number = 0, recursive: boolean = true, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getTagCloudKey(collectionId, isStandard, sort, search, fromContextId, contextId, recursive); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Invalidate tag collections. + * + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTagCollections(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getTagCollectionsKey(); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Invalidate tag index. + * + * @param {number} [id=0] Tag ID. + * @param {string} [name=''] Tag name. + * @param {number} [collectionId=0] Tag collection ID. + * @param {number} [areaId=0] Tag area ID. + * @param {number} [fromContextId=0] Context ID where the link was displayed. + * @param {number} [contextId=0] Context ID where to search for items. + * @param {boolean} [recursive=true] Search in the context and its children. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTagIndexPerArea(id: number, name: string = '', collectionId: number = 0, areaId: number = 0, + fromContextId: number = 0, contextId: number = 0, recursive: boolean = true, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getTagIndexPerAreaKey(id, name, collectionId, areaId, fromContextId, contextId, recursive); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get cache key for tag cloud. + * + * @param {number} collectionId Tag collection ID. + * @param {boolean} isStandard Whether to return only standard tags. + * @param {string} sort Sort order for display (id, name, rawname, count, flag, isstandard, tagcollid). + * @param {string} search Search string. + * @param {number} fromContextId Context ID where this tag cloud is displayed. + * @param {number} contextId Only retrieve tag instances in this context. + * @param {boolean} recursive Retrieve tag instances in the context and it's children. + * @return {string} Cache key. + */ + protected getTagCloudKey(collectionId: number, isStandard: boolean, sort: string, search: string, fromContextId: number, + contextId: number, recursive: boolean): string { + return this.ROOT_CACHE_KEY + 'cloud:' + collectionId + ':' + (isStandard ? 1 : 0) + ':' + sort + ':' + search + ':' + + fromContextId + ':' + contextId + ':' + (recursive ? 1 : 0); + } + + /** + * Get cache key for tag collections. + * + * @return {string} Cache key. + */ + protected getTagCollectionsKey(): string { + return this.ROOT_CACHE_KEY + 'collections'; + } + + /** + * Get cache key for tag index. + * + * @param {number} id Tag ID. + * @param {string} name Tag name. + * @param {number} collectionId Tag collection ID. + * @param {number} areaId Tag area ID. + * @param {number} fromContextId Context ID where the link was displayed. + * @param {number} contextId Context ID where to search for items. + * @param {boolean} [recursive=true] Search in the context and its children. + * @return {string} Cache key. + */ + protected getTagIndexPerAreaKey(id: number, name: string, collectionId: number, areaId: number, fromContextId: number, + contextId: number, recursive: boolean): string { + return this.ROOT_CACHE_KEY + 'index:' + id + ':' + name + ':' + collectionId + ':' + areaId + ':' + fromContextId + ':' + + contextId + ':' + (recursive ? 1 : 0); + } +} diff --git a/src/core/tag/tag.module.ts b/src/core/tag/tag.module.ts new file mode 100644 index 00000000000..970e66e46b9 --- /dev/null +++ b/src/core/tag/tag.module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreTagProvider } from './providers/tag'; +import { CoreTagHelperProvider } from './providers/helper'; +import { CoreTagAreaDelegate } from './providers/area-delegate'; +import { CoreTagMainMenuHandler } from './providers/mainmenu-handler'; +import { CoreTagIndexLinkHandler } from './providers/index-link-handler'; +import { CoreTagSearchLinkHandler } from './providers/search-link-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + CoreTagProvider, + CoreTagHelperProvider, + CoreTagAreaDelegate, + CoreTagMainMenuHandler, + CoreTagIndexLinkHandler, + CoreTagSearchLinkHandler + ] +}) +export class CoreTagModule { + + constructor(mainMenuDelegate: CoreMainMenuDelegate, mainMenuHandler: CoreTagMainMenuHandler, + contentLinksDelegate: CoreContentLinksDelegate, indexLinkHandler: CoreTagIndexLinkHandler, + searchLinkHandler: CoreTagSearchLinkHandler) { + mainMenuDelegate.registerHandler(mainMenuHandler); + contentLinksDelegate.registerHandler(indexLinkHandler); + contentLinksDelegate.registerHandler(searchLinkHandler); + } +} diff --git a/src/core/user/components/components.module.ts b/src/core/user/components/components.module.ts index 7e7427c178e..e741ad964ff 100644 --- a/src/core/user/components/components.module.ts +++ b/src/core/user/components/components.module.ts @@ -18,6 +18,7 @@ import { IonicModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreUserParticipantsComponent } from './participants/participants'; import { CoreUserProfileFieldComponent } from './user-profile-field/user-profile-field'; +import { CoreUserTagAreaComponent } from './tag-area/tag-area'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; @@ -25,7 +26,8 @@ import { CorePipesModule } from '@pipes/pipes.module'; @NgModule({ declarations: [ CoreUserParticipantsComponent, - CoreUserProfileFieldComponent + CoreUserProfileFieldComponent, + CoreUserTagAreaComponent ], imports: [ CommonModule, @@ -39,10 +41,12 @@ import { CorePipesModule } from '@pipes/pipes.module'; ], exports: [ CoreUserParticipantsComponent, - CoreUserProfileFieldComponent + CoreUserProfileFieldComponent, + CoreUserTagAreaComponent ], entryComponents: [ - CoreUserParticipantsComponent + CoreUserParticipantsComponent, + CoreUserTagAreaComponent ] }) export class CoreUserComponentsModule {} diff --git a/src/core/user/components/tag-area/core-user-tag-area.html b/src/core/user/components/tag-area/core-user-tag-area.html new file mode 100644 index 00000000000..8ca11b857cb --- /dev/null +++ b/src/core/user/components/tag-area/core-user-tag-area.html @@ -0,0 +1,4 @@ + + +

{{ item.fullname }}

+
diff --git a/src/core/user/components/tag-area/tag-area.ts b/src/core/user/components/tag-area/tag-area.ts new file mode 100644 index 00000000000..8c4f016121c --- /dev/null +++ b/src/core/user/components/tag-area/tag-area.ts @@ -0,0 +1,26 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; + +/** + * Component to render the user tag area. + */ +@Component({ + selector: 'core-user-tag-area', + templateUrl: 'core-user-tag-area.html' +}) +export class CoreUserTagAreaComponent { + @Input() items: any[]; // Area items to render. +} diff --git a/src/core/user/providers/course-option-handler.ts b/src/core/user/providers/course-option-handler.ts index 4f91bb0ff49..9636dcfeb27 100644 --- a/src/core/user/providers/course-option-handler.ts +++ b/src/core/user/providers/course-option-handler.ts @@ -79,10 +79,10 @@ export class CoreUserParticipantsCourseOptionHandler implements CoreCourseOption * Returns the data needed to render the handler. * * @param {Injector} injector Injector. - * @param {number} courseId The course ID. + * @param {number} course The course. * @return {CoreCourseOptionsHandlerData|Promise} Data or promise resolved with the data. */ - getDisplayData(injector: Injector, courseId: number): CoreCourseOptionsHandlerData | Promise { + getDisplayData(injector: Injector, course: any): CoreCourseOptionsHandlerData | Promise { return { title: 'core.user.participants', class: 'core-user-participants-handler', diff --git a/src/core/user/providers/participants-link-handler.ts b/src/core/user/providers/participants-link-handler.ts index b9bb3eddd8f..fb9971f02a5 100644 --- a/src/core/user/providers/participants-link-handler.ts +++ b/src/core/user/providers/participants-link-handler.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreUserProvider } from './user'; @@ -30,9 +30,9 @@ export class CoreUserParticipantsLinkHandler extends CoreContentLinksHandlerBase featureName = 'CoreCourseOptionsDelegate_CoreUserParticipants'; pattern = /\/user\/index\.php/; - constructor(private userProvider: CoreUserProvider, private loginHelper: CoreLoginHelperProvider, + constructor(private userProvider: CoreUserProvider, private courseHelper: CoreCourseHelperProvider, private domUtils: CoreDomUtilsProvider, - private linkHelper: CoreContentLinksHelperProvider) { + private linkHelper: CoreContentLinksHelperProvider, private courseProvider: CoreCourseProvider) { super(); } @@ -51,6 +51,14 @@ export class CoreUserParticipantsLinkHandler extends CoreContentLinksHandlerBase return [{ action: (siteId, navCtrl?): void => { + // Check if we already are in the course index page. + if (this.courseProvider.currentViewIsCourse(navCtrl, courseId)) { + // Current view is this course, just select the participants tab. + this.courseProvider.selectCourseTab('CoreUserParticipants'); + + return; + } + const modal = this.domUtils.showModalLoading(); this.courseHelper.getCourse(courseId, siteId).then((result) => { @@ -59,8 +67,9 @@ export class CoreUserParticipantsLinkHandler extends CoreContentLinksHandlerBase selectedTab: 'CoreUserParticipants' }; - // Always use redirect to make it the new history root (to avoid "loops" in history). - return this.loginHelper.redirect('CoreCourseSectionPage', params, siteId); + return this.linkHelper.goInSite(navCtrl, 'CoreCourseSectionPage', params, siteId).catch(() => { + // Ignore errors. + }); }).catch(() => { // Cannot get course for some reason, just open the participants page. return this.linkHelper.goInSite(navCtrl, 'CoreUserParticipantsPage', {courseId: courseId}, siteId); diff --git a/src/core/user/providers/tag-area-handler.ts b/src/core/user/providers/tag-area-handler.ts new file mode 100644 index 00000000000..ab2d167cf6f --- /dev/null +++ b/src/core/user/providers/tag-area-handler.ts @@ -0,0 +1,82 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTagAreaHandler } from '@core/tag/providers/area-delegate'; +import { CoreUserTagAreaComponent } from '../components/tag-area/tag-area'; + +/** + * Handler to support tags. + */ +@Injectable() +export class CoreUserTagAreaHandler implements CoreTagAreaHandler { + name = 'CoreUserTagAreaHandler'; + type = 'core/user'; + + constructor(private domUtils: CoreDomUtilsProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param {string} content Rendered content. + * @return {any[]|Promise} Area items (or promise resolved with the items). + */ + parseContent(content: string): any[] | Promise { + const items = []; + const element = this.domUtils.convertToElement(content); + + Array.from(element.querySelectorAll('div.user-box')).forEach((userbox: HTMLElement) => { + const item: any = {}; + + const avatarLink = userbox.querySelector('a:first-child'); + if (!avatarLink) { + return; + } + + const profileUrl = avatarLink.getAttribute('href') || ''; + const match = profileUrl.match(/.*\/user\/(?:profile|view)\.php\?id=(\d+)/); + if (!match) { + return; + } + + item.id = parseInt(match[1], 10); + const avatarImg = avatarLink.querySelector('img.userpicture'); + item.profileimageurl = avatarImg ? avatarImg.getAttribute('src') : ''; + item.fullname = userbox.innerText; + + items.push(item); + }); + + return items; + } + + /** + * Get the component to use to display items. + * + * @param {Injector} injector Injector. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreUserTagAreaComponent; + } +} diff --git a/src/core/user/providers/user-link-handler.ts b/src/core/user/providers/user-link-handler.ts index faaa55c8185..eb9a0b0b65a 100644 --- a/src/core/user/providers/user-link-handler.ts +++ b/src/core/user/providers/user-link-handler.ts @@ -47,7 +47,6 @@ export class CoreUserProfileLinkHandler extends CoreContentLinksHandlerBase { courseId: params.course, userId: parseInt(params.id, 10) }; - // Always use redirect to make it the new history root (to avoid "loops" in history). this.linkHelper.goInSite(navCtrl, 'CoreUserProfilePage', pageParams, siteId); } }]; diff --git a/src/core/user/user.module.ts b/src/core/user/user.module.ts index 845f2179ea7..0370243a687 100644 --- a/src/core/user/user.module.ts +++ b/src/core/user/user.module.ts @@ -30,6 +30,8 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreUserOfflineProvider } from './providers/offline'; import { CoreUserSyncProvider } from './providers/sync'; import { CoreUserSyncCronHandler } from './providers/sync-cron-handler'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; +import { CoreUserTagAreaHandler } from './providers/tag-area-handler'; // List of providers (without handlers). export const CORE_USER_PROVIDERS: any[] = [ @@ -59,6 +61,7 @@ export const CORE_USER_PROVIDERS: any[] = [ CoreUserParticipantsCourseOptionHandler, CoreUserParticipantsLinkHandler, CoreUserSyncCronHandler, + CoreUserTagAreaHandler ] }) export class CoreUserModule { @@ -67,13 +70,14 @@ export class CoreUserModule { contentLinksDelegate: CoreContentLinksDelegate, userLinkHandler: CoreUserProfileLinkHandler, courseOptionHandler: CoreUserParticipantsCourseOptionHandler, linkHandler: CoreUserParticipantsLinkHandler, courseOptionsDelegate: CoreCourseOptionsDelegate, cronDelegate: CoreCronDelegate, - syncHandler: CoreUserSyncCronHandler) { + syncHandler: CoreUserSyncCronHandler, tagAreaDelegate: CoreTagAreaDelegate, tagAreaHandler: CoreUserTagAreaHandler) { userDelegate.registerHandler(userProfileMailHandler); courseOptionsDelegate.registerHandler(courseOptionHandler); contentLinksDelegate.registerHandler(userLinkHandler); contentLinksDelegate.registerHandler(linkHandler); cronDelegate.register(syncHandler); + tagAreaDelegate.registerHandler(tagAreaHandler); eventsProvider.on(CoreEventsProvider.USER_DELETED, (data) => { // Search for userid in params. diff --git a/src/directives/external-content.ts b/src/directives/external-content.ts index 019cd4657c8..4026e7e0a25 100644 --- a/src/directives/external-content.ts +++ b/src/directives/external-content.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, Input, AfterViewInit, ElementRef, OnChanges, SimpleChange } from '@angular/core'; +import { Directive, Input, AfterViewInit, ElementRef, OnChanges, SimpleChange, Output, EventEmitter } from '@angular/core'; import { Platform } from 'ionic-angular'; import { CoreAppProvider } from '@providers/app'; import { CoreLoggerProvider } from '@providers/logger'; @@ -43,11 +43,15 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges { @Input() href?: string; @Input('target-src') targetSrc?: string; @Input() poster?: string; + @Output() onLoad = new EventEmitter(); // Emitted when content is loaded. Only for images. + loaded = false; protected element: HTMLElement; protected logger; protected initialized = false; + invalid = false; + constructor(element: ElementRef, logger: CoreLoggerProvider, private filepoolProvider: CoreFilepoolProvider, private platform: Platform, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private appProvider: CoreAppProvider, private utils: CoreUtilsProvider) { @@ -140,11 +144,30 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges { } } else { + this.invalid = true; + + return; + } + + // Avoid handling data url's. + if (url && url.indexOf('data:') === 0) { + this.invalid = true; + this.onLoad.emit(); + this.loaded = true; + return; } this.handleExternalContent(targetAttr, url, siteId).catch(() => { - // Ignore errors. + // Error handling content. Make sure the loaded event is triggered for images. + if (tagName === 'IMG') { + if (url) { + this.waitForLoad(); + } else { + this.onLoad.emit(); + this.loaded = true; + } + } }); } @@ -225,7 +248,12 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges { // The browser does not catch changes in SRC, we need to add a new source. this.addSource(finalUrl); } else { + if (tagName === 'IMG') { + this.loaded = false; + this.waitForLoad(); + } this.element.setAttribute(targetAttr, finalUrl); + this.element.setAttribute('data-original-' + targetAttr, url); } // Set events to download big files (not downloaded automatically). @@ -288,4 +316,19 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges { this.element.setAttribute('style', inlineStyles); }); } + + /** + * Wait for the image to be loaded or error, and emit an event when it happens. + */ + protected waitForLoad(): void { + const listener = (): void => { + this.element.removeEventListener('load', listener); + this.element.removeEventListener('error', listener); + this.onLoad.emit(); + this.loaded = true; + }; + + this.element.addEventListener('load', listener); + this.element.addEventListener('error', listener); + } } diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index 8cfbd306740..ede668a6b73 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -90,8 +90,9 @@ export class CoreFormatTextDirective implements OnChanges { * Apply CoreExternalContentDirective to a certain element. * * @param {HTMLElement} element Element to add the attributes to. + * @return {CoreExternalContentDirective} External content instance. */ - protected addExternalContent(element: HTMLElement): void { + protected addExternalContent(element: HTMLElement): CoreExternalContentDirective { // Angular 2 doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. const extContent = new CoreExternalContentDirective( element, this.loggerProvider, this.filepoolProvider, this.platform, this.sitesProvider, this.domUtils, this.urlUtils, this.appProvider, this.utils); @@ -105,6 +106,8 @@ export class CoreFormatTextDirective implements OnChanges { extContent.poster = element.getAttribute('poster'); extContent.ngAfterViewInit(); + + return extContent; } /** @@ -117,15 +120,13 @@ export class CoreFormatTextDirective implements OnChanges { } /** - * Wrap an image with a container to adapt its width and, if needed, add an anchor to view it in full size. + * Wrap an image with a container to adapt its width. * - * @param {number} elWidth Width of the directive's element. * @param {HTMLElement} img Image to adapt. */ - protected adaptImage(elWidth: number, img: HTMLElement): void { - const imgWidth = this.getElementWidth(img), - // Element to wrap the image. - container = document.createElement('span'), + protected adaptImage(img: HTMLElement): void { + // Element to wrap the image. + const container = document.createElement('span'), originalWidth = img.attributes.getNamedItem('width'); const forcedWidth = parseInt(originalWidth && originalWidth.value); @@ -152,36 +153,53 @@ export class CoreFormatTextDirective implements OnChanges { } this.domUtils.wrapElement(img, container); - - if (imgWidth > elWidth) { - // The image has been adapted, add an anchor to view it in full size. - this.addMagnifyingGlass(container, img); - } } /** - * Add a magnifying glass icon to view an image at full size. - * - * @param {HTMLElement} container The container of the image. - * @param {HTMLElement} img The image. + * Add magnifying glass icons to view adapted images at full size. */ - addMagnifyingGlass(container: HTMLElement, img: HTMLElement): void { - const imgSrc = this.textUtils.escapeHTML(img.getAttribute('src')), + addMagnifyingGlasses(): void { + const imgs = Array.from(this.element.querySelectorAll('.core-adapted-img-container > img')); + if (!imgs.length) { + return; + } + + // If cannot calculate element's width, use viewport width to avoid false adapt image icons appearing. + const elWidth = this.getElementWidth(this.element) || window.innerWidth; + + imgs.forEach((img: HTMLImageElement) => { + // Skip image if it's inside a link. + if (img.closest('a')) { + return; + } + + let imgWidth = parseInt(img.getAttribute('width')); + if (!imgWidth) { + // No width attribute, use real size. + imgWidth = img.naturalWidth; + } + + if (imgWidth <= elWidth) { + return; + } + + const imgSrc = this.textUtils.escapeHTML(img.getAttribute('data-original-src') || img.getAttribute('src')), label = this.textUtils.escapeHTML(this.translate.instant('core.openfullimage')), anchor = document.createElement('a'); - anchor.classList.add('core-image-viewer-icon'); - anchor.setAttribute('aria-label', label); - // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. - anchor.innerHTML = ''; + anchor.classList.add('core-image-viewer-icon'); + anchor.setAttribute('aria-label', label); + // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. + anchor.innerHTML = ''; - anchor.addEventListener('click', (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - this.domUtils.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId); - }); + anchor.addEventListener('click', (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this.domUtils.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId); + }); - container.appendChild(anchor); + img.parentNode.appendChild(anchor); + }); } /** @@ -307,12 +325,8 @@ export class CoreFormatTextDirective implements OnChanges { // Calculate the height now. this.calculateHeight(); - // Wait for images to load and calculate the height again if needed. - this.domUtils.waitForImages(this.element).then((hasImgToLoad) => { - if (hasImgToLoad) { - this.calculateHeight(); - } - }); + // Add magnifying glasses to images. + this.addMagnifyingGlasses(); if (!this.loadingChangedListener) { // Recalculate the height if a parent core-loading displays the content. @@ -325,6 +339,9 @@ export class CoreFormatTextDirective implements OnChanges { } } else { this.domUtils.moveChildren(div, this.element); + + // Add magnifying glasses to images. + this.addMagnifyingGlasses(); } this.element.classList.remove('core-disable-media-adapt'); @@ -352,7 +369,8 @@ export class CoreFormatTextDirective implements OnChanges { this.utils.isTrueOrOne(this.singleLine), undefined, this.highlight); }).then((formatted) => { const div = document.createElement('div'), - canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']); + canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']), + navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; let images, anchors, audios, @@ -379,23 +397,26 @@ export class CoreFormatTextDirective implements OnChanges { anchors.forEach((anchor) => { // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually. const linkDir = new CoreLinkDirective(anchor, this.domUtils, this.utils, this.sitesProvider, this.urlUtils, - this.contentLinksHelper, this.navCtrl, this.content, this.svComponent); + this.contentLinksHelper, this.navCtrl, this.content, this.svComponent, this.textUtils); linkDir.capture = true; linkDir.ngOnInit(); this.addExternalContent(anchor); }); + const externalImages: CoreExternalContentDirective[] = []; if (images && images.length > 0) { - // If cannot calculate element's width, use a medium number to avoid false adapt image icons appearing. - const elWidth = this.getElementWidth(this.element) || 100; - // Walk through the content to find images, and add our directive. images.forEach((img: HTMLElement) => { this.addMediaAdaptClass(img); - this.addExternalContent(img); + + const externalImage = this.addExternalContent(img); + if (!externalImage.invalid) { + externalImages.push(externalImage); + } + if (this.utils.isTrueOrOne(this.adaptImg) && !img.classList.contains('icon')) { - this.adaptImage(elWidth, img); + this.adaptImage(img); } }); } @@ -405,12 +426,12 @@ export class CoreFormatTextDirective implements OnChanges { }); videos.forEach((video) => { - this.treatVideoFilters(video); + this.treatVideoFilters(video, navCtrl); this.treatMedia(video); }); iframes.forEach((iframe) => { - this.treatIframe(iframe, site, canTreatVimeo); + this.treatIframe(iframe, site, canTreatVimeo, navCtrl); }); // Handle buttons with inner links. @@ -439,12 +460,37 @@ export class CoreFormatTextDirective implements OnChanges { // Handle all kind of frames. frames.forEach((frame: any) => { - this.iframeUtils.treatFrame(frame); + this.iframeUtils.treatFrame(frame, false, navCtrl); }); this.domUtils.handleBootstrapTooltips(div); - return div; + // Wait for images to load. + let promise: Promise = null; + if (externalImages.length) { + // Automatically reject the promise after 5 seconds to prevent blocking the user forever. + promise = this.utils.timeoutPromise(this.utils.allPromises(externalImages.map((externalImage): any => { + if (externalImage.loaded) { + // Image has already been loaded, no need to wait. + return Promise.resolve(); + } + + return new Promise((resolve): void => { + const subscription = externalImage.onLoad.subscribe(() => { + subscription.unsubscribe(); + resolve(); + }); + }); + })), 5000); + } else { + promise = Promise.resolve(); + } + + return promise.catch(() => { + // Ignore errors. So content gets always shown. + }).then(() => { + return div; + }); }); } @@ -508,8 +554,9 @@ export class CoreFormatTextDirective implements OnChanges { * Treat video filters. Currently only treating youtube video using video JS. * * @param {HTMLElement} el Video element. + * @param {NavController} navCtrl NavController to use. */ - protected treatVideoFilters(video: HTMLElement): void { + protected treatVideoFilters(video: HTMLElement, navCtrl: NavController): void { // Treat Video JS Youtube video links and translate them to iframes. if (!video.classList.contains('video-js')) { return; @@ -534,7 +581,7 @@ export class CoreFormatTextDirective implements OnChanges { // Replace video tag by the iframe. video.parentNode.replaceChild(iframe, video); - this.iframeUtils.treatFrame(iframe); + this.iframeUtils.treatFrame(iframe, false, navCtrl); } /** @@ -571,8 +618,9 @@ export class CoreFormatTextDirective implements OnChanges { * @param {HTMLIFrameElement} iframe Iframe to treat. * @param {CoreSite} site Site instance. * @param {boolean} canTreatVimeo Whether Vimeo videos can be treated in the site. + * @param {NavController} navCtrl NavController to use. */ - protected treatIframe(iframe: HTMLIFrameElement, site: CoreSite, canTreatVimeo: boolean): void { + protected treatIframe(iframe: HTMLIFrameElement, site: CoreSite, canTreatVimeo: boolean, navCtrl: NavController): void { const src = iframe.src, currentSite = this.sitesProvider.getCurrentSite(); @@ -583,7 +631,7 @@ export class CoreFormatTextDirective implements OnChanges { currentSite.getAutoLoginUrl(src, false).then((finalUrl) => { iframe.src = finalUrl; - this.iframeUtils.treatFrame(iframe); + this.iframeUtils.treatFrame(iframe, false, navCtrl); }); return; @@ -644,7 +692,7 @@ export class CoreFormatTextDirective implements OnChanges { } } - this.iframeUtils.treatFrame(iframe); + this.iframeUtils.treatFrame(iframe, false, navCtrl); } /** diff --git a/src/directives/link.ts b/src/directives/link.ts index 0540eb83faf..a10f69fded8 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -21,6 +21,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreConfigConstants } from '../configconstants'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; /** * Directive to open a link in external browser. @@ -41,7 +42,8 @@ export class CoreLinkDirective implements OnInit { constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, private sitesProvider: CoreSitesProvider, private urlUtils: CoreUrlUtilsProvider, private contentLinksHelper: CoreContentLinksHelperProvider, @Optional() private navCtrl: NavController, - @Optional() private content: Content, @Optional() private svComponent: CoreSplitViewComponent) { + @Optional() private content: Content, @Optional() private svComponent: CoreSplitViewComponent, + private textUtils: CoreTextUtilsProvider) { // This directive can be added dynamically. In that case, the first param is the anchor HTMLElement. this.element = element.nativeElement || element; } @@ -62,12 +64,13 @@ export class CoreLinkDirective implements OnInit { this.element.addEventListener('click', (event) => { // If the event prevented default action, do nothing. if (!event.defaultPrevented) { - const href = this.element.getAttribute('href'); + let href = this.element.getAttribute('href'); if (href) { event.preventDefault(); event.stopPropagation(); if (this.utils.isTrueOrOne(this.capture)) { + href = this.textUtils.decodeURI(href); this.contentLinksHelper.handleLink(href, undefined, navCtrl, true, true).then((treated) => { if (!treated) { this.navigate(href); diff --git a/src/directives/user-link.ts b/src/directives/user-link.ts index d1714bacb50..981e489b1f9 100644 --- a/src/directives/user-link.ts +++ b/src/directives/user-link.ts @@ -14,6 +14,7 @@ import { Directive, Input, OnInit, ElementRef, Optional } from '@angular/core'; import { NavController } from 'ionic-angular'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Directive to go to user profile on click. @@ -27,7 +28,10 @@ export class CoreUserLinkDirective implements OnInit { protected element: HTMLElement; - constructor(element: ElementRef, @Optional() private navCtrl: NavController) { + constructor(element: ElementRef, + @Optional() private navCtrl: NavController, + @Optional() private svComponent: CoreSplitViewComponent) { + // This directive can be added dynamically. In that case, the first param is the anchor HTMLElement. this.element = element.nativeElement || element; } @@ -41,7 +45,10 @@ export class CoreUserLinkDirective implements OnInit { if (!event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); - this.navCtrl.push('CoreUserProfilePage', { userId: this.userId, courseId: this.courseId }); + + // Decide which navCtrl to use. If this directive is inside a split view, use the split view's master nav. + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + navCtrl.push('CoreUserProfilePage', { userId: this.userId, courseId: this.courseId }); } }); } diff --git a/src/lang/en.json b/src/lang/en.json index bf2f305e543..8c5698b50ef 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -25,9 +25,6 @@ "clicktohideshow": "Click to expand or collapse", "clicktoseefull": "Click to see full contents.", "close": "Close", - "comments": "Comments", - "commentscount": "Comments ({{$a}})", - "commentsnotworking": "Comments cannot be retrieved", "completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", "completion-alt-auto-n": "Not completed: {{$a}}", "completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", @@ -40,6 +37,8 @@ "completion-alt-manual-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as not complete.", "confirmcanceledit": "Are you sure you want to leave this page? All changes will be lost.", "confirmdeletefile": "Are you sure you want to delete this file?", + "confirmgotabroot": "Are you sure you want to go back to {{name}}?", + "confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", "confirmloss": "Are you sure? All changes will be lost.", "confirmopeninbrowser": "Do you want to open it in a web browser?", "considereddigitalminor": "You are too young to create an account on this site.", @@ -49,6 +48,7 @@ "copiedtoclipboard": "Text copied to clipboard", "course": "Course", "coursedetails": "Course details", + "coursenogroups": "You are not a member of any group of this course.", "currentdevice": "Current device", "datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.", "date": "Date", @@ -91,6 +91,7 @@ "erroropenfilenoextension": "Error opening file: the file doesn't have an extension.", "erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.", "errorrenamefile": "Error renaming file. Please try again.", + "errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.", "errorsync": "An error occurred while synchronising. Please try again.", "errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", "explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", @@ -102,6 +103,7 @@ "forcepasswordchangenotice": "You must change your password to proceed.", "fulllistofcourses": "All courses", "fullnameandsitename": "{{fullname}} ({{sitename}})", + "group": "Group", "groupsseparate": "Separate groups", "groupsvisible": "Visible groups", "hasdatatosync": "This {{$a}} has offline data to be synchronised.", @@ -113,6 +115,7 @@ "image": "Image", "imageviewer": "Image viewer", "info": "Information", + "invalidformdata": "Incorrect form data", "ios": "iOS", "labelsep": ":", "lastaccess": "Last access", @@ -165,19 +168,20 @@ "never": "Never", "next": "Next", "no": "No", - "nocomments": "No comments", "nograde": "No grade", "none": "None", "nopasswordchangeforced": "You cannot proceed without changing your password.", "nopermissionerror": "Sorry, but you do not currently have permissions to do that", "nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", "noresults": "No results", + "noselection": "No selection", "notapplicable": "n/a", "notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", "notice": "Notice", "notingroup": "Sorry, but you need to be part of a group to see this page.", "notsent": "Not sent", "now": "now", + "nummore": "{{$a}} more", "numwords": "{{$a}} words", "offline": "Offline", "ok": "OK", @@ -212,10 +216,14 @@ "sec": "sec", "secs": "secs", "seemoredetail": "Click here to see more detail", + "selectacategory": "Please select a category", + "selectacourse": "Select a course", + "selectagroup": "Select a group", "send": "Send", "sending": "Sending", "serverconnection": "Error connecting to the server", "show": "Show", + "showless": "Show less...", "showmore": "Show more...", "site": "Site", "sitemaintenance": "The site is undergoing maintenance and is currently not available", @@ -262,6 +270,7 @@ "unlimited": "Unlimited", "unzipping": "Unzipping", "upgraderunning": "Site is being upgraded, please retry later.", + "user": "User", "userdeleted": "This user account has been deleted", "userdetails": "User details", "usernotfullysetup": "User not fully set-up", diff --git a/src/providers/events.ts b/src/providers/events.ts index 650cc55e833..48f3dcf322c 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -48,6 +48,7 @@ export class CoreEventsProvider { static COURSE_STATUS_CHANGED = 'course_status_changed'; static SECTION_STATUS_CHANGED = 'section_status_changed'; static SITE_PLUGINS_LOADED = 'site_plugins_loaded'; + static SITE_PLUGINS_COURSE_RESTRICT_UPDATED = 'site_plugins_course_restrict_updated'; static LOGIN_SITE_CHECKED = 'login_site_checked'; static LOGIN_SITE_UNCHECKED = 'login_site_unchecked'; static IAB_LOAD_START = 'inappbrowser_load_start'; @@ -60,6 +61,7 @@ export class CoreEventsProvider { static LOAD_PAGE_MAIN_MENU = 'load_page_main_menu'; static SEND_ON_ENTER_CHANGED = 'send_on_enter_changed'; static MAIN_MENU_OPEN = 'main_menu_open'; + static SELECT_COURSE_TAB = 'select_course_tab'; protected logger; protected observables: { [s: string]: Subject } = {}; diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index 09d873f9478..aded3de7e83 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -2197,9 +2197,27 @@ export class CoreFilepoolProvider { filename = this.urlUtils.getLastFileWithoutParams(fileUrl); } + // If there are hashes in the URL, extract them. + const index = filename.indexOf('#'); + let hashes; + + if (index != -1) { + hashes = filename.split('#'); + + // Remove the URL from the array. + hashes.shift(); + + filename = filename.substr(0, index); + } + // Remove the extension from the filename. filename = this.mimeUtils.removeExtension(filename); + if (hashes) { + // Add hashes to the name. + filename += '_' + hashes.join('_'); + } + return this.textUtils.removeSpecialCharactersForFiles(filename); } diff --git a/src/providers/groups.ts b/src/providers/groups.ts index a6507bc1ff0..35d0d2e11fa 100644 --- a/src/providers/groups.ts +++ b/src/providers/groups.ts @@ -39,6 +39,12 @@ export interface CoreGroupInfo { * @type {boolean} */ visibleGroups?: boolean; + + /** + * The group ID to use by default. If all participants is visible, 0 will be used. First group ID otherwise. + * @type {number} + */ + defaultGroupId?: number; } /* @@ -103,7 +109,7 @@ export class CoreGroupsProvider { return Promise.reject(null); } - return response.groups; + return response; }); }); } @@ -138,7 +144,9 @@ export class CoreGroupsProvider { return this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache); } - return []; + return { + groups: [] + }; }); } @@ -146,13 +154,13 @@ export class CoreGroupsProvider { * Helper function to get activity group info (group mode and list of groups). * * @param {number} cmId Course module ID. - * @param {boolean} [addAllParts=true] Whether to add the all participants option. Always true for visible groups. + * @param {boolean} [addAllParts] Deprecated. * @param {number} [userId] User ID. If not defined, use current user. * @param {string} [siteId] Site ID. If not defined, current site. * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). * @return {Promise} Promise resolved with the group info. */ - getActivityGroupInfo(cmId: number, addAllParts: boolean = true, userId?: number, siteId?: string, ignoreCache?: boolean) + getActivityGroupInfo(cmId: number, addAllParts?: boolean, userId?: number, siteId?: string, ignoreCache?: boolean) : Promise { const groupInfo: CoreGroupInfo = { @@ -167,16 +175,25 @@ export class CoreGroupsProvider { return this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache); } - return []; - }).then((groups) => { - if (groups.length <= 0) { + return { + groups: [], + canaccessallgroups: false + }; + }).then((result) => { + if (result.groups.length <= 0) { groupInfo.separateGroups = false; groupInfo.visibleGroups = false; + groupInfo.defaultGroupId = 0; } else { - if (addAllParts || groupInfo.visibleGroups) { + // The "canaccessallgroups" field was added in 3.4. Add all participants for visible groups in previous versions. + if (result.canaccessallgroups || (typeof result.canaccessallgroups == 'undefined' && groupInfo.visibleGroups)) { groupInfo.groups.push({ id: 0, name: this.translate.instant('core.allparticipants') }); + groupInfo.defaultGroupId = 0; + } else { + groupInfo.defaultGroupId = result.groups[0].id; } - groupInfo.groups = groupInfo.groups.concat(groups); + + groupInfo.groups = groupInfo.groups.concat(result.groups); } return groupInfo; @@ -417,4 +434,22 @@ export class CoreGroupsProvider { return site.invalidateWsCacheForKey(this.getUserGroupsInCourseCacheKey(courseId, userId)); }); } + + /** + * Validate a group ID. If the group is not visible by the user, it will return the first group ID. + * + * @param {number} groupId Group ID to validate. + * @param {CoreGroupInfo} groupInfo Group info. + * @return {number} Group ID to use. + */ + validateGroupId(groupId: number, groupInfo: CoreGroupInfo): number { + if (groupId > 0 && groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { + // Check if the group is in the list of groups. + if (groupInfo.groups.some((group) => groupId == group.id)) { + return groupId; + } + } + + return groupInfo.defaultGroupId; + } } diff --git a/src/providers/lang.ts b/src/providers/lang.ts index 04f8283ee26..68463d4e34e 100644 --- a/src/providers/lang.ts +++ b/src/providers/lang.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import * as moment from 'moment'; import { Globalization } from '@ionic-native/globalization'; -import { Platform } from 'ionic-angular'; +import { Platform, Config } from 'ionic-angular'; import { CoreConfigProvider } from './config'; import { CoreConfigConstants } from '../configconstants'; @@ -33,7 +33,7 @@ export class CoreLangProvider { protected sitePluginsStrings = {}; // Strings defined by site plugins. constructor(private translate: TranslateService, private configProvider: CoreConfigProvider, platform: Platform, - private globalization: Globalization) { + private globalization: Globalization, private config: Config) { // Set fallback language and language to use until the app determines the right language to use. translate.setDefaultLang(this.fallbackLanguage); translate.use(this.defaultLanguage); @@ -86,6 +86,17 @@ export class CoreLangProvider { } } + /** + * Capitalize a string (make the first letter uppercase). + * We cannot use a function from text utils because it would cause a circular dependency. + * + * @param {string} value String to capitalize. + * @return {string} Capitalized string. + */ + protected capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); + } + /** * Change current language. * @@ -142,6 +153,13 @@ export class CoreLangProvider { // Use british english when parent english is loaded. moment.locale(language == 'en' ? 'en-gb' : language); + + // Set data for ion-datetime. + this.config.set('monthNames', moment.months().map(this.capitalize.bind(this))); + this.config.set('monthShortNames', moment.monthsShort().map(this.capitalize.bind(this))); + this.config.set('dayNames', moment.weekdays().map(this.capitalize.bind(this))); + this.config.set('dayShortNames', moment.weekdaysShort().map(this.capitalize.bind(this))); + this.currentLanguage = language; return Promise.all(promises).finally(() => { diff --git a/src/providers/sites.ts b/src/providers/sites.ts index b64da98fdd1..515d7117aac 100644 --- a/src/providers/sites.ts +++ b/src/providers/sites.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from './app'; @@ -22,12 +22,13 @@ import { CoreSitesFactoryProvider } from './sites-factory'; import { CoreTextUtilsProvider } from './utils/text'; import { CoreUrlUtilsProvider } from './utils/url'; import { CoreUtilsProvider } from './utils/utils'; +import { CoreWSProvider } from './ws'; import { CoreConstants } from '@core/constants'; import { CoreConfigConstants } from '../configconstants'; import { CoreSite } from '@classes/site'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { Md5 } from 'ts-md5/dist/md5'; -import { Location } from '@angular/common'; +import { WP_PROVIDER } from '@app/app.module'; /** * Response of checking if a site exists and its configuration. @@ -243,10 +244,14 @@ export class CoreSitesProvider { ]; // Constants to validate a site version. + protected WORKPLACE_APP = 3; + protected MOODLE_APP = 2; protected VALID_VERSION = 1; protected LEGACY_APP_VERSION = 0; protected INVALID_VERSION = -1; + protected isWPApp: boolean; + protected logger; protected services = {}; protected sessionRestored = false; @@ -321,8 +326,8 @@ export class CoreSitesProvider { constructor(logger: CoreLoggerProvider, private http: HttpClient, private sitesFactory: CoreSitesFactoryProvider, private appProvider: CoreAppProvider, private translate: TranslateService, private urlUtils: CoreUrlUtilsProvider, - private eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, private location: Location, - private utils: CoreUtilsProvider) { + private eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, + private utils: CoreUtilsProvider, private injector: Injector, private wsProvider: CoreWSProvider) { this.logger = logger.getInstance('CoreSitesProvider'); this.appDB = appProvider.getDB(); @@ -363,7 +368,7 @@ export class CoreSitesProvider { return this.checkSiteWithProtocol(siteUrl, protocol).catch((error) => { // Do not continue checking if a critical error happened. if (error.critical) { - return Promise.reject(error.error); + return Promise.reject(error); } // Retry with the other protocol. @@ -371,13 +376,17 @@ export class CoreSitesProvider { return this.checkSiteWithProtocol(siteUrl, protocol).catch((secondError) => { if (secondError.critical) { - return Promise.reject(secondError.error); + return Promise.reject(secondError); } // Site doesn't exist. Return the error message. - return Promise.reject(this.textUtils.getErrorMessageFromError(error) || - this.textUtils.getErrorMessageFromError(secondError) || - this.translate.instant('core.cannotconnect')); + if (this.textUtils.getErrorMessageFromError(error)) { + return Promise.reject(error); + } else if (this.textUtils.getErrorMessageFromError(secondError)) { + return Promise.reject(secondError); + } else { + return this.translate.instant('core.cannotconnect'); + } }); }); } @@ -415,8 +424,11 @@ export class CoreSitesProvider { } // Return the error message. - return Promise.reject(this.textUtils.getErrorMessageFromError(error) || - this.textUtils.getErrorMessageFromError(secondError)); + if (this.textUtils.getErrorMessageFromError(error)) { + return Promise.reject(error); + } else { + return Promise.reject(secondError); + } }); }).then(() => { // Create a temporary site to check if local_mobile is installed. @@ -456,7 +468,23 @@ export class CoreSitesProvider { // Error, check if not supported. if (error.available === 1) { // Service supported but an error happened. Return error. - return Promise.reject({ error: error.error }); + error.critical = true; + + if (error.errorcode == 'codingerror') { + // This could be caused by a redirect. Check if it's the case. + return this.utils.checkRedirect(siteUrl).then((redirect) => { + if (redirect) { + error.error = this.translate.instant('core.login.sitehasredirect'); + } else { + // We can't be sure if there is a redirect or not. Display cannot connect error. + error.error = this.translate.instant('core.cannotconnect'); + } + + return Promise.reject(error); + }); + } + + return Promise.reject(error); } return data; @@ -464,6 +492,9 @@ export class CoreSitesProvider { } return data; + }, (error) => { + // Local mobile check returned an error. This only happens if the plugin is installed and it returns an error. + return rejectWithCriticalError(error); }).then((data) => { siteUrl = temporarySite.getURL(); @@ -488,7 +519,8 @@ export class CoreSitesProvider { * @return {Promise} A promise to be resolved if the site exists. */ siteExists(siteUrl: string): Promise { - return this.http.post(siteUrl + '/login/token.php', {}).timeout(CoreConstants.WS_TIMEOUT).toPromise().catch(() => { + return this.http.post(siteUrl + '/login/token.php', {}).timeout(this.wsProvider.getRequestTimeout()).toPromise() + .catch(() => { // Default error messages are kinda bad, return our own message. return Promise.reject({error: this.translate.instant('core.cannotconnect')}); }).then((data: any) => { @@ -528,7 +560,7 @@ export class CoreSitesProvider { service: service }, loginUrl = siteUrl + '/login/token.php', - promise = this.http.post(loginUrl, params).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + promise = this.http.post(loginUrl, params).timeout(this.wsProvider.getRequestTimeout()).toPromise(); return promise.then((data: any): any => { if (typeof data == 'undefined') { @@ -642,30 +674,68 @@ export class CoreSitesProvider { return siteId; }); }); - } else if (result == this.LEGACY_APP_VERSION) { - let errorKey = 'core.login.legacymoodleversion', - params; + } + + return this.treatInvalidAppVersion(result, siteUrl); + }); + } + + /** + * Having the result of isValidMoodleVersion, it treats the error message to be shown. + * + * @param {number} result Result returned by isValidMoodleVersion function. + * @param {string} siteUrl The site url. + * @param {string} siteId If site is already added, it will invalidate the token. + * @return {Promise} A promise rejected with the error info. + */ + protected treatInvalidAppVersion(result: number, siteUrl: string, siteId?: string): Promise { + let errorCode, + errorKey, + errorExtra = '', + errorKeyParams; + + switch (result) { + case this.LEGACY_APP_VERSION: + errorKey = 'core.login.legacymoodleversion'; + errorCode = 'legacymoodleversion'; if (this.appProvider.isDesktop()) { errorKey += 'desktop'; - params = {$a: siteUrl}; + errorKeyParams = {$a: siteUrl}; } - let error = this.translate.instant(errorKey, params); if (this.appProvider.isWindows() || this.appProvider.isLinux()) { - error += this.translate.instant('core.login.legacymoodleversiondesktopdownloadold'); + errorExtra = this.translate.instant('core.login.legacymoodleversiondesktopdownloadold'); } - return Promise.reject({ - error: error, - errorcode: 'legacymoodleversion' - }); - } else { - return Promise.reject({ - error: this.translate.instant('core.login.invalidmoodleversion'), - errorcode: 'invalidmoodleversion' - }); - } + break; + case this.MOODLE_APP: + errorKey = 'core.login.connecttomoodleapp'; + errorCode = 'connecttomoodleapp'; + break; + case this.WORKPLACE_APP: + errorKey = 'core.login.connecttoworkplaceapp'; + errorCode = 'connecttoworkplaceapp'; + break; + default: + errorCode = 'invalidmoodleversion'; + errorKey = 'core.login.invalidmoodleversion'; + } + + let promise; + + if (siteId) { + promise = this.setSiteLoggedOut(siteId, true); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return Promise.reject({ + error: this.translate.instant(errorKey, errorKeyParams) + errorExtra, + errorcode: errorCode, + loggedout: true + }); }); } @@ -709,7 +779,7 @@ export class CoreSitesProvider { * Check for the minimum required version. * * @param {any} info Site info. - * @return {number} Either VALID_VERSION, LEGACY_APP_VERSION or INVALID_VERSION. + * @return {number} Either VALID_VERSION, LEGACY_APP_VERSION, WORKPLACE_APP, MOODLE_APP or INVALID_VERSION. */ protected isValidMoodleVersion(info: any): number { if (!info) { @@ -726,11 +796,9 @@ export class CoreSitesProvider { const version = parseInt(info.version, 10); if (!isNaN(version)) { if (version >= version31) { - return this.VALID_VERSION; + return this.validateWorkplaceVersion(info); } else if (version >= version24) { return this.LEGACY_APP_VERSION; - } else { - return this.INVALID_VERSION; } } } @@ -739,7 +807,7 @@ export class CoreSitesProvider { const release = this.getReleaseNumber(info.release || ''); if (release) { if (release >= release31) { - return this.VALID_VERSION; + return this.validateWorkplaceVersion(info); } if (release >= release24) { return this.LEGACY_APP_VERSION; @@ -750,6 +818,33 @@ export class CoreSitesProvider { return this.INVALID_VERSION; } + /** + * Check if needs to be redirected to specific Workplace App or general Moodle App. + * + * @param {any} info Site info. + * @return {number} Either VALID_VERSION, WORKPLACE_APP or MOODLE_APP. + */ + protected validateWorkplaceVersion(info: any): number { + const isWorkplace = !!info.functions && info.functions.some((func) => { + return func.name == 'tool_program_get_user_programs'; + }); + + if (typeof this.isWPApp == 'undefined') { + this.isWPApp = !!WP_PROVIDER && WP_PROVIDER.name == 'AddonBlockProgramsOverviewModule' && + !!this.injector.get(WP_PROVIDER, false); + } + + if (!this.isWPApp && isWorkplace) { + return this.WORKPLACE_APP; + } + + if (this.isWPApp && !isWorkplace) { + return this.MOODLE_APP; + } + + return this.VALID_VERSION; + } + /** * Returns the release number from site release info. * @@ -1144,27 +1239,23 @@ export class CoreSitesProvider { * @return {Promise} Promise resolved when the user is logged out. */ logout(): Promise { - if (!this.currentSite) { - // Already logged out. - return Promise.resolve(); - } + let siteId; + const promises = []; - const siteId = this.currentSite.getId(), - siteConfig = this.currentSite.getStoredConfig(), - promises = []; + if (this.currentSite) { + const siteConfig = this.currentSite.getStoredConfig(); + siteId = this.currentSite.getId(); - this.currentSite = undefined; + this.currentSite = undefined; - if (siteConfig && siteConfig.tool_mobile_forcelogout == '1') { - promises.push(this.setSiteLoggedOut(siteId, true)); - } + if (siteConfig && siteConfig.tool_mobile_forcelogout == '1') { + promises.push(this.setSiteLoggedOut(siteId, true)); + } - promises.push(this.appDB.deleteRecords(this.CURRENT_SITE_TABLE, { id: 1 })); + promises.push(this.appDB.deleteRecords(this.CURRENT_SITE_TABLE, { id: 1 })); + } return Promise.all(promises).finally(() => { - // Due to DeepLinker, we need to remove the path from the URL, otherwise some pages are re-created when they shouldn't. - this.location.replaceState(''); - this.eventsProvider.trigger(CoreEventsProvider.LOGOUT, {}, siteId); }); } @@ -1270,9 +1361,10 @@ export class CoreSitesProvider { return site.fetchSiteInfo().then((info) => { site.setInfo(info); - if (this.isLegacyMoodleByInfo(info)) { + const versionCheck = this.isValidMoodleVersion(info); + if (versionCheck != this.VALID_VERSION) { // The Moodle version is not supported, reject. - return Promise.reject(this.translate.instant('core.login.legacymoodleversion')); + return this.treatInvalidAppVersion(versionCheck, site.getURL(), site.getId()); } // Try to get the site config. @@ -1293,6 +1385,8 @@ export class CoreSitesProvider { this.eventsProvider.trigger(CoreEventsProvider.SITE_UPDATED, info, siteId); }); }); + }).catch((error) => { + // Ignore that we cannot fetch site info. Probably the auth token is invalid. }); }); } diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 4f4fdab17ca..8776024e79d 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -22,6 +22,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreTextUtilsProvider } from './text'; import { CoreAppProvider } from '../app'; import { CoreConfigProvider } from '../config'; +import { CoreConfigConstants } from '../../configconstants'; import { CoreUrlUtilsProvider } from './url'; import { CoreFileProvider } from '@providers/file'; import { CoreConstants } from '@core/constants'; @@ -74,6 +75,10 @@ export class CoreDomUtilsProvider { configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { this.debugDisplay = !!debugDisplay; }); + // Set the font size based on user preference. + configProvider.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConfigConstants.font_sizes[0]).then((fontSize) => { + document.documentElement.style.fontSize = fontSize + '%'; + }); } /** @@ -323,6 +328,33 @@ export class CoreDomUtilsProvider { return urls; } + /** + * Fix syntax errors in HTML. + * + * @param {string} html HTML text. + * @return {string} Fixed HTML text. + */ + fixHtml(html: string): string { + this.template.innerHTML = html; + + const attrNameRegExp = /[^\x00-\x20\x7F-\x9F"'>\/=]+/; + + const fixElement = (element: Element): void => { + // Remove attributes with an invalid name. + Array.from(element.attributes).forEach((attr) => { + if (!attrNameRegExp.test(attr.name)) { + element.removeAttributeNode(attr); + } + }); + + Array.from(element.children).forEach(fixElement); + }; + + Array.from(this.template.content.children).forEach(fixElement); + + return this.template.innerHTML; + } + /** * Focus an element and open keyboard. * @@ -598,6 +630,64 @@ export class CoreDomUtilsProvider { return this.textUtils.decodeHTML(this.translate.instant('core.error')); } + /** + * Get the error message from an error, including debug data if needed. + * + * @param {any} error Message to show. + * @param {boolean} [needsTranslate] Whether the error needs to be translated. + * @return {string} Error message, null if no error should be displayed. + */ + getErrorMessage(error: any, needsTranslate?: boolean): string { + let extraInfo = ''; + + if (typeof error == 'object') { + if (this.debugDisplay) { + // Get the debug info. Escape the HTML so it is displayed as it is in the view. + if (error.debuginfo) { + extraInfo = '

' + this.textUtils.escapeHTML(error.debuginfo); + } + if (error.backtrace) { + extraInfo += '

' + this.textUtils.replaceNewLines(this.textUtils.escapeHTML(error.backtrace), '
'); + } + + // tslint:disable-next-line + console.error(error); + } + + // We received an object instead of a string. Search for common properties. + if (error.coreCanceled) { + // It's a canceled error, don't display an error. + return null; + } + + error = this.textUtils.getErrorMessageFromError(error); + if (!error) { + // No common properties found, just stringify it. + error = JSON.stringify(error); + extraInfo = ''; // No need to add extra info because it's already in the error. + } + + // Try to remove tokens from the contents. + const matches = error.match(/token"?[=|:]"?(\w*)/, ''); + if (matches && matches[1]) { + error = error.replace(new RegExp(matches[1], 'g'), 'secret'); + } + } + + if (error == CoreConstants.DONT_SHOW_ERROR) { + // The error shouldn't be shown, stop. + return null; + } + + let message = this.textUtils.decodeHTML(needsTranslate ? this.translate.instant(error) : error); + + if (extraInfo) { + message += extraInfo; + } + + return message; + } + /** * Retrieve component/directive instance. * Please use this function only if you cannot retrieve the instance using parent/child methods: ViewChild (or similar) @@ -612,6 +702,45 @@ export class CoreDomUtilsProvider { return this.instances[id]; } + /** + * Wait an element to exists using the findFunction. + * + * @param {Function} findFunction The function used to find the element. + * @return {Promise} Resolved if found, rejected if too many tries. + */ + waitElementToExist(findFunction: Function): Promise { + const promiseInterval = { + promise: null, + resolve: null, + reject: null + }; + + let tries = 100; + + promiseInterval.promise = new Promise((resolve, reject): void => { + promiseInterval.resolve = resolve; + promiseInterval.reject = reject; + }); + + const clear = setInterval(() => { + const element: HTMLElement = findFunction(); + + if (element) { + clearInterval(clear); + promiseInterval.resolve(element); + } else { + tries--; + + if (tries <= 0) { + clearInterval(clear); + promiseInterval.reject(); + } + } + }, 100); + + return promiseInterval.promise; + } + /** * Handle bootstrap tooltips in a certain element. * @@ -705,16 +834,18 @@ export class CoreDomUtilsProvider { * * @param {HTMLElement} oldParent The old parent. * @param {HTMLElement} newParent The new parent. + * @param {boolean} [prepend] If true, adds the children to the beginning of the new parent. * @return {Node[]} List of moved children. */ - moveChildren(oldParent: HTMLElement, newParent: HTMLElement): Node[] { + moveChildren(oldParent: HTMLElement, newParent: HTMLElement, prepend?: boolean): Node[] { const movedChildren: Node[] = []; + const referenceNode = prepend ? newParent.firstChild : null; while (oldParent.childNodes.length > 0) { const child = oldParent.childNodes[0]; movedChildren.push(child); - newParent.appendChild(child); + newParent.insertBefore(child, referenceNode); } return movedChildren; @@ -1138,51 +1269,11 @@ export class CoreDomUtilsProvider { * @return {Promise} Promise resolved with the alert modal. */ showErrorModal(error: any, needsTranslate?: boolean, autocloseTime?: number): Promise { - let extraInfo = ''; - - if (typeof error == 'object') { - if (this.debugDisplay) { - // Get the debug info. Escape the HTML so it is displayed as it is in the view. - if (error.debuginfo) { - extraInfo = '

' + this.textUtils.escapeHTML(error.debuginfo); - } - if (error.backtrace) { - extraInfo += '

' + this.textUtils.replaceNewLines(this.textUtils.escapeHTML(error.backtrace), '
'); - } - - // tslint:disable-next-line - console.error(error); - } - - // We received an object instead of a string. Search for common properties. - if (error.coreCanceled) { - // It's a canceled error, don't display an error. - return; - } - - error = this.textUtils.getErrorMessageFromError(error); - if (!error) { - // No common properties found, just stringify it. - error = JSON.stringify(error); - extraInfo = ''; // No need to add extra info because it's already in the error. - } - - // Try to remove tokens from the contents. - const matches = error.match(/token"?[=|:]"?(\w*)/, ''); - if (matches && matches[1]) { - error = error.replace(new RegExp(matches[1], 'g'), 'secret'); - } - } - - if (error == CoreConstants.DONT_SHOW_ERROR) { - // The error shouldn't be shown, stop. - return; - } - - let message = this.textUtils.decodeHTML(needsTranslate ? this.translate.instant(error) : error); + const message = this.getErrorMessage(error, needsTranslate); - if (extraInfo) { - message += extraInfo; + if (message === null) { + // Message doesn't need to be displayed, stop. + return Promise.resolve(null); } return this.showAlert(this.getErrorTitle(message), message, undefined, autocloseTime); @@ -1344,9 +1435,12 @@ export class CoreDomUtilsProvider { * @param {boolean} [needsTranslate] Whether the 'text' needs to be translated. * @param {number} [duration=2000] Duration in ms of the dimissable toast. * @param {string} [cssClass=""] Class to add to the toast. + * @param {boolean} [dismissOnPageChange=true] Dismiss the Toast on page change. * @return {Toast} Toast instance. */ - showToast(text: string, needsTranslate?: boolean, duration: number = 2000, cssClass: string = ''): Toast { + showToast(text: string, needsTranslate?: boolean, duration: number = 2000, cssClass: string = '', + dismissOnPageChange: boolean = true): Toast { + if (needsTranslate) { text = this.translate.instant(text); } @@ -1356,7 +1450,7 @@ export class CoreDomUtilsProvider { duration: duration, position: 'bottom', cssClass: cssClass, - dismissOnPageChange: true + dismissOnPageChange: dismissOnPageChange }); loader.present(); diff --git a/src/providers/utils/iframe.ts b/src/providers/utils/iframe.ts index 6771a6ed124..2952c22b1c6 100644 --- a/src/providers/utils/iframe.ts +++ b/src/providers/utils/iframe.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable, NgZone } from '@angular/core'; -import { Config, Platform } from 'ionic-angular'; +import { Config, Platform, NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { Network } from '@ionic-native/network'; import { CoreAppProvider } from '../app'; @@ -191,8 +191,9 @@ export class CoreIframeUtilsProvider { * @param {any} element Element to treat (iframe, embed, ...). * @param {Window} contentWindow The window of the element contents. * @param {Document} contentDocument The document of the element contents. + * @param {NavController} [navCtrl] NavController to use if a link can be opened in the app. */ - redefineWindowOpen(element: any, contentWindow: Window, contentDocument: Document): void { + redefineWindowOpen(element: any, contentWindow: Window, contentDocument: Document, navCtrl?: NavController): void { if (contentWindow) { // Intercept window.open. contentWindow.open = (url: string, target: string): Window => { @@ -229,13 +230,18 @@ export class CoreIframeUtilsProvider { this.domUtils.showErrorModal(error); }); } else { - // It's an external link, we will open with browser. Check if we need to auto-login. - if (!this.sitesProvider.isLoggedIn()) { - // Not logged in, cannot auto-login. - this.utils.openInBrowser(url); - } else { - this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); - } + // It's an external link, check if it can be opened in the app. + this.contentLinksHelper.handleLink(url, undefined, navCtrl, true, true).then((treated) => { + if (!treated) { + // Not opened in the app, open with browser. Check if we need to auto-login + if (!this.sitesProvider.isLoggedIn()) { + // Not logged in, cannot auto-login. + this.utils.openInBrowser(url); + } else { + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); + } + } + }); } // We cannot create new Window objects directly, return null which is a valid return value for Window.open(). @@ -248,7 +254,7 @@ export class CoreIframeUtilsProvider { CoreIframeUtilsProvider.FRAME_TAGS.forEach((tag) => { const elements = Array.from(contentDocument.querySelectorAll(tag)); elements.forEach((subElement) => { - this.treatFrame(subElement, true); + this.treatFrame(subElement, true, navCtrl); }); }); } @@ -260,14 +266,15 @@ export class CoreIframeUtilsProvider { * * @param {any} element Element to treat (iframe, embed, ...). * @param {boolean} [isSubframe] Whether it's a frame inside another frame. + * @param {NavController} [navCtrl] NavController to use if a link can be opened in the app. */ - treatFrame(element: any, isSubframe?: boolean): void { + treatFrame(element: any, isSubframe?: boolean, navCtrl?: NavController): void { if (element) { this.checkOnlineFrameInOffline(element, isSubframe); let winAndDoc = this.getContentWindowAndDocument(element); // Redefine window.open in this element and sub frames, it might have been loaded already. - this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document); + this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document, navCtrl); // Treat links. this.treatFrameLinks(element, winAndDoc.document); @@ -276,7 +283,7 @@ export class CoreIframeUtilsProvider { // Element loaded, redefine window.open and treat links again. winAndDoc = this.getContentWindowAndDocument(element); - this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document); + this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document, navCtrl); this.treatFrameLinks(element, winAndDoc.document); if (winAndDoc.window) { diff --git a/src/providers/utils/time.ts b/src/providers/utils/time.ts index e0c5633e11c..9be16dd5865 100644 --- a/src/providers/utils/time.ts +++ b/src/providers/utils/time.ts @@ -118,6 +118,29 @@ export class CoreTimeUtilsProvider { return converted; } + /** + * Fix format to use in an ion-datetime. + * + * @param {string} format Format to use. + * @return {string} Fixed format. + */ + fixFormatForDatetime(format: string): string { + if (!format) { + return ''; + } + + // The component ion-datetime doesn't support escaping characters ([]), so we remove them. + let fixed = format.replace(/[\[\]]/g, ''); + + if (fixed.indexOf('A') != -1) { + // Do not use am/pm format because there is a bug in ion-datetime. + fixed = fixed.replace(/ ?A/g, ''); + fixed = fixed.replace(/h/g, 'H'); + } + + return fixed; + } + /** * Returns hours, minutes and seconds in a human readable format * @@ -276,6 +299,18 @@ export class CoreTimeUtilsProvider { return moment(timestamp).format(format); } + /** + * Convert a timestamp to the format to set to a datetime input. + * + * @param {number} [timestamp] Timestamp to convert (in ms). If not provided, current time. + * @return {string} Formatted time. + */ + toDatetimeFormat(timestamp?: number): string { + timestamp = timestamp || Date.now(); + + return this.userDate(timestamp, 'YYYY-MM-DDTHH:mm:ss.SSS', false) + 'Z'; + } + /** * Convert a text into user timezone timestamp. * @@ -283,7 +318,11 @@ export class CoreTimeUtilsProvider { * @return {number} Converted timestamp. */ convertToTimestamp(date: string): number { - return moment(date).unix() - (moment().utcOffset() * 60); + if (typeof date == 'string' && date.slice(-1) == 'Z') { + return moment(date).unix() - (moment().utcOffset() * 60); + } + + return moment(date).unix(); } /** diff --git a/src/providers/utils/url.ts b/src/providers/utils/url.ts index 3035f53c847..a7b47de344b 100644 --- a/src/providers/utils/url.ts +++ b/src/providers/utils/url.ts @@ -44,6 +44,17 @@ export class CoreUrlUtilsProvider { return url; } + /** + * Given a URL and a text, return an HTML link. + * + * @param {string} url URL. + * @param {string} text Text of the link. + * @return {string} Link. + */ + buildLink(url: string, text: string): string { + return '' + text + ''; + } + /** * Extracts the parameters from a URL and stores them in an object. * @@ -68,7 +79,7 @@ export class CoreUrlUtilsProvider { } urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => { - params[key] = typeof value != 'undefined' ? value : ''; + params[key] = typeof value != 'undefined' ? this.textUtils.decodeURIComponent(value) : ''; if (subParams) { params[key] = params[key].replace(subParamsPlaceholder, subParams); diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index e1595c75ba9..bc73ce6ca47 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -21,12 +21,12 @@ import { WebIntent } from '@ionic-native/web-intent'; import { CoreAppProvider } from '../app'; import { CoreDomUtilsProvider } from './dom'; import { CoreMimetypeUtilsProvider } from './mimetype'; +import { CoreTextUtilsProvider } from './text'; import { CoreEventsProvider } from '../events'; import { CoreLoggerProvider } from '../logger'; import { TranslateService } from '@ngx-translate/core'; import { CoreLangProvider } from '../lang'; import { CoreWSProvider, CoreWSError } from '../ws'; -import { CoreConstants } from '@core/constants'; /** * Deferred promise. It's similar to the result of $q.defer() in AngularJS. @@ -58,6 +58,7 @@ export interface PromiseDefer { */ @Injectable() export class CoreUtilsProvider { + protected DONT_CLONE = ['[object FileEntry]', '[object DirectoryEntry]', '[object DOMFileSystem]']; protected logger; protected iabInstance: InAppBrowserObject; protected uniqueIds: {[name: string]: number} = {}; @@ -66,10 +67,36 @@ export class CoreUtilsProvider { private domUtils: CoreDomUtilsProvider, logger: CoreLoggerProvider, private translate: TranslateService, private platform: Platform, private langProvider: CoreLangProvider, private eventsProvider: CoreEventsProvider, private fileOpener: FileOpener, private mimetypeUtils: CoreMimetypeUtilsProvider, private webIntent: WebIntent, - private wsProvider: CoreWSProvider, private zone: NgZone) { + private wsProvider: CoreWSProvider, private zone: NgZone, private textUtils: CoreTextUtilsProvider) { this.logger = logger.getInstance('CoreUtilsProvider'); } + /** + * Given an error, add an extra warning to the error message and return the new error message. + * + * @param {any} error Error object or message. + * @param {any} [defaultError] Message to show if the error is not a string. + * @return {string} New error message. + */ + addDataNotDownloadedError(error: any, defaultError?: string): string { + let errorMessage = error; + + if (error && typeof error != 'string') { + errorMessage = this.textUtils.getErrorMessageFromError(error); + } + + if (typeof errorMessage != 'string') { + errorMessage = defaultError || ''; + } + + if (!this.isWebServiceError(error)) { + // Local error. Add an extra warning. + errorMessage += '

' + this.translate.instant('core.errorsomedatanotdownloaded'); + } + + return errorMessage; + } + /** * Similar to Promise.all, but if a promise fails this function's promise won't be rejected until ALL promises have finished. * @@ -205,7 +232,8 @@ export class CoreUtilsProvider { initOptions.signal = controller.signal; } - return this.timeoutPromise(window.fetch(url, initOptions), CoreConstants.WS_TIMEOUT).then((response: Response) => { + return this.timeoutPromise(window.fetch(url, initOptions), this.wsProvider.getRequestTimeout()) + .then((response: Response) => { return response.redirected; }).catch((error) => { if (error.timeout && controller) { @@ -240,22 +268,36 @@ export class CoreUtilsProvider { * Clone a variable. It should be an object, array or primitive type. * * @param {any} source The variable to clone. + * @param {number} [level=0] Depth we are right now inside a cloned object. It's used to prevent reaching max call stack size. * @return {any} Cloned variable. */ - clone(source: any): any { + clone(source: any, level: number = 0): any { + if (level >= 20) { + // Max 20 levels. + this.logger.error('Max depth reached when cloning object.', source); + + return source; + } + if (Array.isArray(source)) { // Clone the array and all the entries. const newArray = []; for (let i = 0; i < source.length; i++) { - newArray[i] = this.clone(source[i]); + newArray[i] = this.clone(source[i], level + 1); } return newArray; } else if (typeof source == 'object' && source !== null) { + // Check if the object shouldn't be copied. + if (source && source.toString && this.DONT_CLONE.indexOf(source.toString()) != -1) { + // Object shouldn't be copied, return it as it is. + return source; + } + // Clone the object and all the subproperties. const newObject = {}; for (const name in source) { - newObject[name] = this.clone(source[name]); + newObject[name] = this.clone(source[name], level + 1); } return newObject; @@ -376,13 +418,15 @@ export class CoreUtilsProvider { } /** - * Flatten an object, moving subobjects' properties to the first level using dot notation. E.g.: - * {a: {b: 1, c: 2}, d: 3} -> {'a.b': 1, 'a.c': 2, d: 3} + * Flatten an object, moving subobjects' properties to the first level. + * It supports 2 notations: dot notation and square brackets. + * E.g.: {a: {b: 1, c: 2}, d: 3} -> {'a.b': 1, 'a.c': 2, d: 3} * * @param {object} obj Object to flatten. - * @return {object} Flatten object. + * @param {boolean} [useDotNotation] Whether to use dot notation '.' or square brackets '['. + * @return {object} Flattened object. */ - flattenObject(obj: object): object { + flattenObject(obj: object, useDotNotation?: boolean): object { const toReturn = {}; for (const name in obj) { @@ -398,7 +442,8 @@ export class CoreUtilsProvider { continue; } - toReturn[name + '.' + subName] = flatObject[subName]; + const newName = useDotNotation ? name + '.' + subName : name + '[' + subName + ']'; + toReturn[newName] = flatObject[subName]; } } else { toReturn[name] = value; @@ -1051,6 +1096,37 @@ export class CoreUtilsProvider { return mapped; } + /** + * Convert an object to a format of GET param. E.g.: {a: 1, b: 2} -> a=1&b=2 + * + * @param {any} object Object to convert. + * @param {boolean} [removeEmpty=true] Whether to remove params whose value is null/undefined. + * @return {string} GET params. + */ + objectToGetParams(object: any, removeEmpty: boolean = true): string { + // First of all, flatten the object so all properties are in the first level. + const flattened = this.flattenObject(object); + let result = '', + joinChar = ''; + + for (const name in flattened) { + let value = flattened[name]; + + if (removeEmpty && (value === null || typeof value == 'undefined')) { + continue; + } + + if (typeof value == 'boolean') { + value = value ? 1 : 0; + } + + result += joinChar + name + '=' + value; + joinChar = '&'; + } + + return result; + } + /** * Add a prefix to all the keys in an object. * diff --git a/src/providers/ws.ts b/src/providers/ws.ts index 0cc906f5abc..636b00e0bd9 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -76,6 +76,18 @@ export interface CoreWSAjaxPreSets { * @type {boolean} */ responseExpected?: boolean; + + /** + * Whether to use the no-login endpoint instead of the normal one. Use it for requests that don't require authentication. + * @type {boolean} + */ + noLogin?: boolean; + + /** + * Whether to send the parameters via GET. Only if noLogin is true. + * @type {boolean} + */ + useGet?: boolean; } /** @@ -215,8 +227,7 @@ export class CoreWSProvider { * - available: 0 if unknown, 1 if available, -1 if not available. */ callAjax(method: string, data: any, preSets: CoreWSAjaxPreSets): Promise { - let siteUrl, - ajaxData; + let promise; if (typeof preSets.siteUrl == 'undefined') { return rejectWithError(this.createFakeWSError('core.unexpectederror', true)); @@ -228,17 +239,24 @@ export class CoreWSProvider { preSets.responseExpected = true; } - ajaxData = [{ - index: 0, - methodname: method, - args: this.convertValuesToString(data) - }]; + const script = preSets.noLogin ? 'service-nologin.php' : 'service.php', + ajaxData = JSON.stringify([{ + index: 0, + methodname: method, + args: this.convertValuesToString(data) + }]); // The info= parameter has no function. It is just to help with debugging. // We call it info to match the parameter name use by Moodle's AMD ajax module. - siteUrl = preSets.siteUrl + '/lib/ajax/service.php?info=' + method; + let siteUrl = preSets.siteUrl + '/lib/ajax/' + script + '?info=' + method; - const promise = this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + if (preSets.noLogin && preSets.useGet) { + // Send params using GET. + siteUrl += '&args=' + encodeURIComponent(ajaxData); + promise = this.http.get(siteUrl).timeout(this.getRequestTimeout()).toPromise(); + } else { + promise = this.http.post(siteUrl, ajaxData).timeout(this.getRequestTimeout()).toPromise(); + } return promise.then((data: any) => { // Some moodle web services return null. @@ -498,6 +516,15 @@ export class CoreWSProvider { }); } + /** + * Get a request timeout based on the network connection. + * + * @return {number} Timeout in ms. + */ + getRequestTimeout(): number { + return this.appProvider.isNetworkAccessLimited() ? CoreConstants.WS_TIMEOUT : CoreConstants.WS_TIMEOUT_WIFI; + } + /** * Get the unique queue item id of the cache for a HTTP request. * @@ -524,7 +551,7 @@ export class CoreWSProvider { let promise = this.getPromiseHttp('head', url); if (!promise) { - promise = this.commonHttp.head(url).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + promise = this.commonHttp.head(url).timeout(this.getRequestTimeout()).toPromise(); promise = this.setPromiseHttp(promise, 'head', url); } @@ -555,7 +582,7 @@ export class CoreWSProvider { const requestUrl = siteUrl + '&wsfunction=' + method; // Perform the post request. - const promise = this.http.post(requestUrl, ajaxData, options).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + const promise = this.http.post(requestUrl, ajaxData, options).timeout(this.getRequestTimeout()).toPromise(); return promise.then((data: any) => { // Some moodle web services return null. @@ -675,7 +702,7 @@ export class CoreWSProvider { // HTTP not finished, but we should delete the promise after timeout. timeout = setTimeout(() => { delete this.ongoingCalls[queueItemId]; - }, CoreConstants.WS_TIMEOUT); + }, this.getRequestTimeout()); // HTTP finished, delete from ongoing. return promise.finally(() => { diff --git a/src/theme/format-text.scss b/src/theme/format-text.scss index ad0a230fb4c..8365e736343 100644 --- a/src/theme/format-text.scss +++ b/src/theme/format-text.scss @@ -4,12 +4,15 @@ ion-app.app-root .item core-format-text, ion-app.app-root core-rich-text-editor .core-rte-editor { @include core-headings(); - font-size: 1.4rem; - p { + font-size: 1.4rem; margin-bottom: 1rem; } + .no-overflow { + overflow: auto; + } + // Fix lists styles in core-format-text. ul { padding-left: 1rem; @@ -93,8 +96,6 @@ ion-app.app-root core-rich-text-editor .core-rte-editor { .atto_image_button_right { vertical-align: middle; max-width: 100%; - height: auto; - width: auto; display: inline-block; margin: 0 0.5em; @@ -102,8 +103,6 @@ ion-app.app-root core-rich-text-editor .core-rte-editor { /* If the image is display: block then linking the image to URLs won't work. */ /*display: inline-block;*/ max-width: 100%; - height: auto; - width: auto; } } @@ -176,6 +175,24 @@ ion-app.app-root core-rich-text-editor .core-rte-editor { } } +// Those styles are omitted on RTE. +ion-app.app-root core-format-text, +ion-app.app-root .item core-format-text { + .atto_image_button_text-top, + .atto_image_button_middle, + .atto_image_button_text-bottom, + .atto_image_button_left, + .atto_image_button_right { + height: auto; + width: auto; + + &.img-responsive { + height: auto; + width: auto; + } + } +} + // Special fixes // ------------------------- ion-app.app-root { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 36a6110c366..0cd36e33e51 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -4,6 +4,7 @@ // Font path is used to include ionicons, // roboto, and noto sans fonts $font-path: "../assets/fonts"; +$assets-path: "../assets"; // The app direction is used to include // rtl styles in your app. For more info, please see: @@ -28,13 +29,13 @@ $green: #5e8100; // Accent. $red: #cb3d4d; $orange: #f98012; // Accent (never text). $yellow: #fbad1a; // Accent (never text). +$purple: #8e24aa; // Accent (never text). $core-color: $orange; // Branded apps customization // -------------------------------------------------- @import "bmma"; - $blue-light: mix($blue, white, 20%) !default; // Background. $blue-dark: darken($blue, 10%) !default; @@ -75,19 +76,31 @@ $content-padding: 10px; // colors so you can add, rename and remove colors as needed. // The "primary" color is the only required color in the map. +$primary: $core-color !default; +$secondary: $turquoise !default; +$danger: $red !default; +$light: $gray-lighter !default; +$color-gray: $gray-dark !default; +$dark: $black !default; +$warning: $yellow !default; +$success: $green !default; +$info: $blue !default; +$inverted-base: $white !default; +$inverted-contrast: $primary !default; + $colors: ( - primary: $core-color, - secondary: $turquoise, - danger: $red, - light: $gray-lighter, - gray: $gray-dark, - dark: $black, - warning: $yellow, - success: $green, - info: $blue, + primary: $primary, + secondary: $secondary, + danger: $danger, + light: $light, + gray: $color-gray, + dark: $dark, + warning: $warning, + success: $success, + info: $info, inverted: ( - base: $white, - contrast: $core-color + base: $inverted-base, + contrast: $inverted-contrast ) ); @@ -118,7 +131,16 @@ $refresher-icon-color: $core-color !default; $core-online-color: #5cb85c; -$core-select-placeholder-color: $core-color !default; +$core-placeholder-color: $gray-dark !default; +$core-select-placeholder-color: $core-placeholder-color !default; +$alert-input-placeholder-color: $core-placeholder-color !default; +$core-datetime-ios-placeholder-color: $core-placeholder-color !default; +$searchbar-ios-input-placeholder-color: $core-placeholder-color !default; +$searchbar-md-input-placeholder-color: $core-placeholder-color !default; +$searchbar-wp-input-placeholder-color: $core-placeholder-color !default; +$text-input-placeholder-color: $core-placeholder-color !default; + +$core-select-color: $core-color !default; $item-avatar-size: 54px !default; $input-select-opacity: .5 !default; $note-color: $gray-dark !default; @@ -133,10 +155,7 @@ $core-star-color: $core-color !default; // Init screen. $core-color-init-screen: #ffffff !default; -$core-color-init-screen-alt: #ffffff !default; $core-init-screen-spinner-color: $core-color !default; -$core-init-screen-logo-width: 60% !default; -$core-init-screen-logo-max-width: 300px !default; $core-fixed-url: false !default; @@ -154,6 +173,9 @@ $core-login-loading-color: false !default; $core-login-item-inner-background-color: $white !default; $core-login-item-background-color: $white !default; +$core-action-sheet-color: $core-color !default; +$core-action-sheet-cancel-color: $danger !default; + // App iOS Variables // -------------------------------------------------- // iOS only Sass variables can go here @@ -166,12 +188,16 @@ $tabs-ios-tab-color-inactive: $tabs-tab-color-inactive; $button-ios-outline-background-color: $core-button-outline-background-color; $toolbar-ios-height: 44px + 8; // Avoid toolbar with different heights. $checkbox-ios-icon-border-radius: 0px !default; -$select-ios-placeholder-color: $core-select-placeholder-color; +$select-ios-placeholder-color: $core-select-color !default; +$datetime-ios-placeholder-color: $core-datetime-ios-placeholder-color; $radio-ios-disabled-opacity: $input-select-opacity !default; $checkbox-ios-disabled-opacity: $input-select-opacity !default; $toggle-ios-disabled-opacity: $input-select-opacity !default; $note-ios-color: $note-color; $popover-ios-width: $popover-width; +$action-sheet-ios-title-color: $core-action-sheet-color; +$action-sheet-ios-button-text-color: $black !default; +$action-sheet-ios-button-destructive-text-color: $danger; $item-ios-divider-background: $item-divider-background; $item-ios-divider-color: $item-divider-color; @@ -187,14 +213,16 @@ $spinner-md-crescent-color: $core-spinner-color; $tabs-md-tab-color-inactive: $tabs-tab-color-inactive; $button-md-outline-background-color: $core-button-outline-background-color; $font-family-md-base: "Roboto", "Noto Sans", "Helvetica Neue", sans-serif !default; -$select-md-placeholder-color: $core-select-placeholder-color; +$select-md-placeholder-color: $core-select-color !default; +$datetime-md-placeholder-color: $core-datetime-ios-placeholder-color !default; $label-md-text-color: $text-color !default; $radio-md-disabled-opacity: $input-select-opacity !default; $checkbox-md-disabled-opacity: $input-select-opacity !default; $toggle-md-disabled-opacity: $input-select-opacity !default; $note-md-color: $note-color; $popover-md-width: $popover-width; -$action-sheet-md-title-color: $core-color; +$action-sheet-md-title-color: $core-action-sheet-color; +$action-sheet-md-button-text-color: $black !default; $item-md-divider-background: $item-divider-background; $item-md-divider-color: $item-divider-color; @@ -209,16 +237,42 @@ $loading-wp-spinner-color: $core-loading-spinner-color; $spinner-wp-circles-color: $core-spinner-color; $tabs-wp-tab-color-inactive: $tabs-tab-color-inactive; $button-wp-outline-background-color: $core-button-outline-background-color; -$select-wp-placeholder-color: $core-select-placeholder-color; +$select-wp-placeholder-color: $core-select-color !default; +$datetime-wp-placeholder-color: $core-datetime-ios-placeholder-color !default; $label-wp-text-color: $text-color !default; $radio-wp-disabled-opacity: $input-select-opacity !default; $checkbox-wp-disabled-opacity: $input-select-opacity !default; $toggle-wp-disabled-opacity: $input-select-opacity !default; $note-wp-color: $note-color; $popover-wp-width: $popover-width; +$action-sheet-wp-title-color: $core-action-sheet-color; +$action-sheet-wp-button-text-color: $black !default; $item-wp-divider-background: $item-divider-background; $item-wp-divider-color: $item-divider-color; +// Font sizes +// --------------------------------------------------- +// Some font sizes are defined in absolute pixels by ionic, +// override these with relative sizes so they are resizable. +$alert-ios-message-font-size: 1.4rem; +$alert-ios-title-font-size: 2.2rem; +$alert-ios-sub-title-font-size: 1.6rem; +$alert-md-message-font-size: 1.4rem; +$alert-md-title-font-size: 2.2rem; +$alert-md-sub-title-font-size: 1.6rem; +$alert-button-font-size: 1.4rem; +$tabs-ios-tab-font-size: 1.4rem; +$chip-ios-font-size: 1.3rem; +$chip-md-font-size: 1.3rem; + +// Icon sizes +// --------------------------------------------------- +// Some font icons have relative sizes set by ionic, +// define absolute sizes so they aren't scaled with text. +$tabs-md-tab-icon-size: 24px; +$tabs-md-tab-min-height: 56px; +$tabs-ios-tab-min-height: 56px; + // App Theme // -------------------------------------------------- // Ionic apps can have different themes applied, which can diff --git a/upgrade.txt b/upgrade.txt index 56a1634dd32..6599608e690 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.7.1 === + +- CoreGroupsProvider.getActivityAllowedGroups and CoreGroupsProvider.getActivityAllowedGroupsIfEnabled now return the full response of core_group_get_activity_allowed_groups instead of just the groups. + === 3.7.0 === - The pushnotifications addon has been moved to core. All imports of that addon need to be fixed to use the right path and name.