diff --git a/package-lock.json b/package-lock.json index 6e143e71d..11a5ea75b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,11 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-5.0.0.tgz", "integrity": "sha1-iH4QbIsQOwQVz2FWpCXabYP0yJ0=" }, + "@ionic-native/camera": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@ionic-native/camera/-/camera-4.5.2.tgz", + "integrity": "sha512-sHvyGyCq84FvqNklkBXViUMR75twYhxxdN6P2FT3MgNY9bJ7Z1tZvs+YVqPF+nBbedXEXIeGbYYGbIJnUNLNGQ==" + }, "@ionic-native/clipboard": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/@ionic-native/clipboard/-/clipboard-4.5.2.tgz", @@ -83,6 +88,11 @@ "resolved": "https://registry.npmjs.org/@ionic-native/local-notifications/-/local-notifications-4.5.2.tgz", "integrity": "sha512-/O2hNsWW6ixlAPY9Tw6wfIIUmNOPmd11DcxCTQ5vR8+oGPyYPj3IXkgUCI/U29Y3hDikSxdWTI19FtCxnzYKNA==" }, + "@ionic-native/media-capture": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-4.5.2.tgz", + "integrity": "sha512-WbzU8cVK1Ephk+gSVi1hH/YwbQidvgDyc2Oex+UQO+aVqXoELRmCmL1OZI79xpJnl7qpMaSugDqGYY+zJH4DDQ==" + }, "@ionic-native/network": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/@ionic-native/network/-/network-4.5.2.tgz", @@ -262,66 +272,6 @@ "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-2.0.0.tgz", "integrity": "sha1-nBbQfNBwxnraJwoulAKB64JrP0M=" }, - "@nodert-win10/windows.applicationmodel": { - "version": "0.2.96", - "resolved": "https://registry.npmjs.org/@nodert-win10/windows.applicationmodel/-/windows.applicationmodel-0.2.96.tgz", - "integrity": "sha1-v5Mh8xkB3kcPcWNKlZFbG1Afjr0=", - "dependencies": { - "nan": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", - "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=" - } - } - }, - "@nodert-win10/windows.data.xml.dom": { - "version": "0.2.96", - "resolved": "https://registry.npmjs.org/@nodert-win10/windows.data.xml.dom/-/windows.data.xml.dom-0.2.96.tgz", - "integrity": "sha1-RfH1BrY3X1hdrltdAsMNYBwC1FQ=", - "dependencies": { - "nan": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", - "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=" - } - } - }, - "@nodert-win10/windows.foundation": { - "version": "0.2.96", - "resolved": "https://registry.npmjs.org/@nodert-win10/windows.foundation/-/windows.foundation-0.2.96.tgz", - "integrity": "sha1-vrIGYdh/s1L5xcfEWJm/PCTXjNA=", - "dependencies": { - "nan": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", - "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=" - } - } - }, - "@nodert-win10/windows.ui.notifications": { - "version": "0.2.96", - "resolved": "https://registry.npmjs.org/@nodert-win10/windows.ui.notifications/-/windows.ui.notifications-0.2.96.tgz", - "integrity": "sha1-CwOeBqDARm7C6PJ6uHyxCQZL0LA=", - "dependencies": { - "nan": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", - "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=" - } - } - }, - "@nodert-win10/windows.ui.startscreen": { - "version": "0.2.96", - "resolved": "https://registry.npmjs.org/@nodert-win10/windows.ui.startscreen/-/windows.ui.startscreen-0.2.96.tgz", - "integrity": "sha1-qukSAniyE3Z7glNP5R6u+GRJHYo=", - "dependencies": { - "nan": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", - "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=" - } - } - }, "@types/cordova": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", @@ -357,30 +307,6 @@ "resolved": "https://registry.npmjs.org/@types/promise.prototype.finally/-/promise.prototype.finally-2.0.2.tgz", "integrity": "sha512-Fs99h+iFQZ4ZY2vO3+uJCrx+5KQnJ4FPerZ3oT/1L5aA7vnmK/d7Z/Ml1yHtNCh9UQcjFTR4Xo/Jss2f39Fgtw==" }, - "7zip-bin": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-2.2.7.tgz", - "integrity": "sha512-+rr4OgeTNrLuJAf09o3USdttEYiXvZshWMkhD6wR9v1ieXH0JM1Q2yT41/cJuJcqiPpSXlM/g3aR+Y5MWQdr0Q==", - "optional": true - }, - "7zip-bin-linux": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin-linux/-/7zip-bin-linux-1.2.0.tgz", - "integrity": "sha512-umB98LN18XBGKPw4EKET2zPDqVhEU1mxXA1Gx0BM+DoBt4hnlZPNkpSMNzmuNbQshi9SzLhqlTAyKcAgNrbV3Q==", - "optional": true - }, - "7zip-bin-mac": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/7zip-bin-mac/-/7zip-bin-mac-1.0.1.tgz", - "integrity": "sha1-Pmh3i78JJq3GgVlCcHRQXUdVXAI=", - "optional": true - }, - "7zip-bin-win": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/7zip-bin-win/-/7zip-bin-win-2.1.1.tgz", - "integrity": "sha512-6VGEW7PXGroTsoI2QW3b0ea95HJmbVBHvfANKLLMzSzFA1zKqVX5ybNuhmeGpf6vA0x8FJTt6twpprDANsY5WQ==", - "optional": true - }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -452,7 +378,8 @@ "ansi-styles": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz", - "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==" + "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==", + "dev": true }, "ansi-wrap": { "version": "0.1.0", @@ -471,16 +398,6 @@ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true }, - "archiver": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-2.1.0.tgz", - "integrity": "sha1-0t8ujVdzqCwdzOklzMQUUOqZmv0=" - }, - "archiver-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-1.3.0.tgz", - "integrity": "sha1-5QtMCccL89aA4y/xt5lOn52JUXQ=" - }, "archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", @@ -493,11 +410,6 @@ "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", "dev": true }, - "argparse": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz", - "integrity": "sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY=" - }, "arr-diff": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", @@ -576,18 +488,14 @@ "async": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", - "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==" + "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", + "dev": true }, "async-each": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=" }, - "async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==" - }, "async-foreach": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", @@ -685,27 +593,12 @@ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.11.0.tgz", "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=" }, - "bl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.1.tgz", - "integrity": "sha1-ysMo977kVzDUBLaSID/LWQ4XLV4=" - }, "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=", "dev": true }, - "bluebird": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", - "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" - }, - "bluebird-lst": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.5.tgz", - "integrity": "sha512-Ey0bDNys5qpYPhZ/oQ9vOEvD0TYQDTILMXWP2iGfvMg7rSDde+oV4aQQgqRH+CvBFNz2BSDQnPGMUl6LKBUUQA==" - }, "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", @@ -836,44 +729,12 @@ "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", "dev": true }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" - }, "buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", "dev": true }, - "builder-util": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-3.4.4.tgz", - "integrity": "sha512-TFtm1yzFd3x34dR5dIs8hxAaDvH08ZiEY3QGvwI2lo+UZos+AIeQ4q/pxPU7lBZ+BMNWzVymG/LZcNu6N3g+vQ==", - "dependencies": { - "7zip-bin": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-2.3.4.tgz", - "integrity": "sha512-s2ZfgRWXeNUQTQE3O85CDDrU2Uo90pMlMkTxkz85wQOuzVxB8t4cubMPup3WlTPFKHQgb6lDkAHS3ljkUSFO6A==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "source-map-support": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.0.tgz", - "integrity": "sha512-vUoN3I7fHQe0R/SJLKRdKYuEdRGogsviXFkHHo17AWaTGv17VLnxw+CFXvqy+y4ORZ3doWLQcxRYfwKrsd/H7Q==" - } - } - }, - "builder-util-runtime": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-3.4.1.tgz", - "integrity": "sha512-I5fvn41z+vdjPvDZD6RigjyGyWQqjAh8Rs2IVbCI7HXlnEHkyT6Sl5fsS85eAjzs+huLzdbyABHz2CxGviPfWg==" - }, "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", @@ -925,18 +786,14 @@ "chalk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz", - "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==" + "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==", + "dev": true }, "chokidar": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=" }, - "ci-info": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.1.2.tgz", - "integrity": "sha512-uTGIPNx/nSpBdsF6xnseRXLLtfr9VLqkz8ZqHXr3Y7b6SftyRxBGjwMtJj1OhNbmlc1wZzLNAlAcvyIiE8a6ZA==" - }, "cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -982,12 +839,14 @@ "color-convert": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz", - "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==" + "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==", + "dev": true }, "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true }, "combined-stream": { "version": "1.0.5", @@ -1001,11 +860,6 @@ "integrity": "sha512-BFnaq5ZOGcDN7FlrtBT4xxkgIToalIIxwjxLWVJ8bGTpe1LroqMiqQXdA7ygc7CRvaYS+9zfPGFnJqFSayx+AA==", "dev": true }, - "compress-commons": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-1.2.2.tgz", - "integrity": "sha1-UkqfEJA/OoEzibAiXSfEi7dRiQ8=" - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1069,16 +923,6 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, - "crc": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.5.0.tgz", - "integrity": "sha1-mLi6fUiWZbo5efWbITgTdBAaGWQ=" - }, - "crc32-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-2.0.0.tgz", - "integrity": "sha1-483TtN8xaN10494/u8t7KX/pCPQ=" - }, "create-ecdh": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.0.tgz", @@ -1153,11 +997,6 @@ "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", "dev": true }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==" - }, "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -1294,28 +1133,17 @@ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", "dev": true }, - "electron-builder-squirrel-windows": { - "version": "19.49.0", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-19.49.0.tgz", - "integrity": "sha512-9sElHyF4bVtBEH+rS0ADw5+ZFyvu+a2IuDxua380m2W47sGhUfolpAUx+SlmmSQK32GgYYpyZgQv/KiLtTT/ng==" - }, - "electron-to-chromium": { - "version": "1.3.28", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.28.tgz", - "integrity": "sha1-jdTmRYCGZE6fnwoc8y4qH53/2e4=", + "electron-releases": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/electron-releases/-/electron-releases-2.1.0.tgz", + "integrity": "sha512-cyKFD1bTE/UgULXfaueIN1k5EPFzs+FRc/rvCY5tIynefAPqopQEgjr0EzY+U3Dqrk/G4m9tXSPuZ77v6dL/Rw==", "dev": true }, - "electron-windows-notifications": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/electron-windows-notifications/-/electron-windows-notifications-1.1.16.tgz", - "integrity": "sha512-KdgFOA2sjIml52b4aClyd6rAN0Fd3jovddNoxVJ2kAj3ggjIJ2DMQsUXAguXp4C4yGZmvD8esi3T8Ti84FMDsA==", - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==" - } - } + "electron-to-chromium": { + "version": "1.3.30", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.30.tgz", + "integrity": "sha512-zx1Prv7kYLfc4OA60FhxGbSo4qrEjgSzpo1/37i7l9ltXPYOoQBtjQxY9KmsgfHnBxHlBGXwLlsbt/gub1w5lw==", + "dev": true }, "elliptic": { "version": "6.4.0", @@ -1335,11 +1163,6 @@ "integrity": "sha1-eePVhlU0aQn+bw9Fpd5oEDspTSA=", "dev": true }, - "end-of-stream": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.0.tgz", - "integrity": "sha1-epDYM+/abPpurA9JSduw+tOmMgY=" - }, "enhanced-resolve": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz", @@ -1424,7 +1247,8 @@ "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=" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true }, "escope": { "version": "3.6.0", @@ -1432,11 +1256,6 @@ "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=", "dev": true }, - "esprima": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", - "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==" - }, "esrecurse": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.0.tgz", @@ -1681,20 +1500,11 @@ "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=", "dev": true }, - "fs-extra": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", - "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==" - }, - "fs-extra-p": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/fs-extra-p/-/fs-extra-p-4.5.0.tgz", - "integrity": "sha512-V/sdZmV+Yx3+nfXmjRTdBP4mVWCt7hZ0+ZOv+IZo+6fdkBxafaGsI7mYeNv/J3rWyz+mIToCFQORFSwt1bZw8Q==" - }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "fsevents": { "version": "1.1.3", @@ -1704,540 +1514,654 @@ "dependencies": { "abbrev": { "version": "1.1.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz", + "integrity": "sha1-0FVMIlZjbi9W58LlrRg/hZQo2B8=", "optional": true }, "ajv": { "version": "4.11.8", - "bundled": true, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=", "optional": true }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, "aproba": { "version": "1.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.1.1.tgz", + "integrity": "sha1-ldNgDwdxCqDpKYxyatXs8urLq6s=", "optional": true }, "are-we-there-yet": { "version": "1.1.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz", + "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", "optional": true }, "asn1": { "version": "0.2.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", "optional": true }, "assert-plus": { "version": "0.2.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=", "optional": true }, "asynckit": { "version": "0.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "optional": true }, "aws-sign2": { "version": "0.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=", "optional": true }, "aws4": { "version": "1.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", + "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=", "optional": true }, "balanced-match": { "version": "0.4.2", - "bundled": true + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=" }, "bcrypt-pbkdf": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", "optional": true }, "block-stream": { "version": "0.0.9", - "bundled": true + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=" }, "boom": { "version": "2.10.1", - "bundled": true + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=" }, "brace-expansion": { "version": "1.1.7", - "bundled": true + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz", + "integrity": "sha1-Pv/DxQ4ABTH7cg6v+A8K6O8jz1k=" }, "buffer-shims": { "version": "1.0.0", - "bundled": true + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=" }, "caseless": { "version": "0.12.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", "optional": true }, "co": { "version": "4.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "optional": true }, "code-point-at": { "version": "1.1.0", - "bundled": true + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "combined-stream": { "version": "1.0.5", - "bundled": true + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", + "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=" }, "concat-map": { "version": "0.0.1", - "bundled": true + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "core-util-is": { "version": "1.0.2", - "bundled": true + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cryptiles": { "version": "2.0.5", - "bundled": true + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=" }, "dashdash": { "version": "1.14.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", "optional": true, "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "optional": true } } }, "debug": { "version": "2.6.8", - "bundled": true, + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", "optional": true }, "deep-extend": { "version": "0.4.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz", + "integrity": "sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=", "optional": true }, "delayed-stream": { "version": "1.0.0", - "bundled": true + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "optional": true }, "detect-libc": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.2.tgz", + "integrity": "sha1-ca1dIEvxempsqPRQxhRUBm70YeE=", "optional": true }, "ecc-jsbn": { "version": "0.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", "optional": true }, "extend": { "version": "3.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", "optional": true }, "extsprintf": { "version": "1.0.2", - "bundled": true + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz", + "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA=" }, "forever-agent": { "version": "0.6.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", "optional": true }, "form-data": { "version": "2.1.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", "optional": true }, "fs.realpath": { "version": "1.0.0", - "bundled": true + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fstream": { "version": "1.0.11", - "bundled": true + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=" }, "fstream-ignore": { "version": "1.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz", + "integrity": "sha1-nDHa40dnAY/h0kmyTa2mfQktoQU=", "optional": true }, "gauge": { "version": "2.7.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "optional": true }, "getpass": { "version": "0.1.7", - "bundled": true, + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", "optional": true, "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "optional": true } } }, "glob": { "version": "7.1.2", - "bundled": true + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==" }, "graceful-fs": { "version": "4.1.11", - "bundled": true + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" }, "har-schema": { "version": "1.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz", + "integrity": "sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4=", "optional": true }, "har-validator": { "version": "4.2.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz", + "integrity": "sha1-M0gdDxu/9gDdID11gSpqX7oALio=", "optional": true }, "has-unicode": { "version": "2.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "optional": true }, "hawk": { "version": "3.1.3", - "bundled": true + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=" }, "hoek": { "version": "2.16.3", - "bundled": true + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" }, "http-signature": { "version": "1.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", "optional": true }, "inflight": { "version": "1.0.6", - "bundled": true + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=" }, "inherits": { "version": "2.0.3", - "bundled": true + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz", + "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4=", "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "bundled": true + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=" }, "is-typedarray": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "optional": true }, "isarray": { "version": "1.0.0", - "bundled": true + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isstream": { "version": "0.1.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", "optional": true }, "jodid25519": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz", + "integrity": "sha1-BtSRIlUJNBlHfUJWM2BuDpB4KWc=", "optional": true }, "jsbn": { "version": "0.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", "optional": true }, "json-schema": { "version": "0.2.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", "optional": true }, "json-stable-stringify": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", "optional": true }, "json-stringify-safe": { "version": "5.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "optional": true }, "jsonify": { "version": "0.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "optional": true }, "jsprim": { "version": "1.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.0.tgz", + "integrity": "sha1-o7h+QCmNjDgFUtjMdiigu5WiKRg=", "optional": true, "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "optional": true } } }, "mime-db": { "version": "1.27.0", - "bundled": true + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz", + "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE=" }, "mime-types": { "version": "2.1.15", - "bundled": true + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz", + "integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=" }, "minimatch": { "version": "3.0.4", - "bundled": true + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==" }, "minimist": { "version": "0.0.8", - "bundled": true + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "mkdirp": { "version": "0.5.1", - "bundled": true + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=" }, "ms": { "version": "2.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "optional": true }, "node-pre-gyp": { "version": "0.6.39", - "bundled": true, + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz", + "integrity": "sha512-OsJV74qxnvz/AMGgcfZoDaeDXKD3oY3QVIbBmwszTFkRisTSXbMQyn4UWzUMOtA5SVhrBZOTp0wcoSBgfMfMmQ==", "optional": true }, "nopt": { "version": "4.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "optional": true }, "npmlog": { "version": "4.1.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.0.tgz", + "integrity": "sha512-ocolIkZYZt8UveuiDS0yAkkIjid1o7lPG8cYm05yNYzBn8ykQtaiPMEGp8fY9tKdDgm8okpdKzkvu1y9hUYugA==", "optional": true }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, "oauth-sign": { "version": "0.8.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", "optional": true }, "object-assign": { "version": "4.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "optional": true }, "once": { "version": "1.4.0", - "bundled": true + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=" }, "os-homedir": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "optional": true }, "os-tmpdir": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "optional": true }, "osenv": { "version": "0.1.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.4.tgz", + "integrity": "sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ=", "optional": true }, "path-is-absolute": { "version": "1.0.1", - "bundled": true + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "performance-now": { "version": "0.2.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz", + "integrity": "sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU=", "optional": true }, "process-nextick-args": { "version": "1.0.7", - "bundled": true + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" }, "punycode": { "version": "1.4.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", "optional": true }, "qs": { "version": "6.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", + "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=", "optional": true }, "rc": { "version": "1.2.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.1.tgz", + "integrity": "sha1-LgPo5C7kULjLPc5lvhv4l04d/ZU=", "optional": true, "dependencies": { "minimist": { "version": "1.2.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "optional": true } } }, "readable-stream": { "version": "2.2.9", - "bundled": true + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.9.tgz", + "integrity": "sha1-z3jsb0ptHrQ9JkiMrJfwQudLf8g=" }, "request": { "version": "2.81.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/request/-/request-2.81.0.tgz", + "integrity": "sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA=", "optional": true }, "rimraf": { "version": "2.6.1", - "bundled": true + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", + "integrity": "sha1-wjOOxkPfeht/5cVPqG9XQopV8z0=" }, "safe-buffer": { "version": "5.0.1", - "bundled": true + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz", + "integrity": "sha1-0mPKVGls2KMGtcplUekt5XkY++c=" }, "semver": { "version": "5.3.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "optional": true }, "set-blocking": { "version": "2.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "optional": true }, "signal-exit": { "version": "3.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "optional": true }, "sntp": { "version": "1.0.9", - "bundled": true + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=" }, "sshpk": { "version": "1.13.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz", + "integrity": "sha1-/yo+T9BEl1Vf7Zezmg/YL6+zozw=", "optional": true, "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "optional": true } } }, "string_decoder": { "version": "1.0.1", - "bundled": true + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.1.tgz", + "integrity": "sha1-YuIA8DmVWmgQ2N8KM//A8BNmLZg=" }, "string-width": { "version": "1.0.2", - "bundled": true + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=" }, "stringstream": { "version": "0.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=", "optional": true }, "strip-ansi": { "version": "3.0.1", - "bundled": true + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=" }, "strip-json-comments": { "version": "2.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "optional": true }, "tar": { "version": "2.2.1", - "bundled": true + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=" }, "tar-pack": { "version": "3.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.4.0.tgz", + "integrity": "sha1-I74tf2cagzk3bL2wuP4/3r8xeYQ=", "optional": true }, "tough-cookie": { "version": "2.3.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz", + "integrity": "sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo=", "optional": true }, "tunnel-agent": { "version": "0.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", "optional": true }, "tweetnacl": { "version": "0.14.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "optional": true }, "uid-number": { "version": "0.0.6", - "bundled": true, + "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz", + "integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=", "optional": true }, "util-deprecate": { "version": "1.0.2", - "bundled": true + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { "version": "3.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", + "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=", "optional": true }, "verror": { "version": "1.3.6", - "bundled": true, + "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz", + "integrity": "sha1-z/XfEpRtKX0rqu+qJoniW+AcAFw=", "optional": true }, "wide-align": { "version": "1.1.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz", + "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", "optional": true }, "wrappy": { "version": "1.0.2", - "bundled": true + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" } } }, @@ -2291,7 +2215,8 @@ "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==" + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true }, "glob-base": { "version": "0.3.0", @@ -2584,7 +2509,8 @@ "has-flag": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", - "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=" + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true }, "has-gulplog": { "version": "0.1.0", @@ -2710,7 +2636,8 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=" + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true }, "inherits": { "version": "2.0.3", @@ -2720,7 +2647,8 @@ "ini": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true }, "interpret": { "version": "1.1.0", @@ -2783,11 +2711,6 @@ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.3.tgz", "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=" }, - "is-ci": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.0.10.tgz", - "integrity": "sha1-9zkzayYyNlBhqdSCcM1WrjNpMY4=" - }, "is-date-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", @@ -2798,11 +2721,6 @@ "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=" }, - "is-electron-renderer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-electron-renderer/-/is-electron-renderer-2.0.1.tgz", - "integrity": "sha1-pGnQVvl1aXxYyYxgI+sKp5r4laI=" - }, "is-equal-shallow": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", @@ -2950,11 +2868,6 @@ "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", "dev": true }, - "js-yaml": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz", - "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==" - }, "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", @@ -2995,7 +2908,8 @@ "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=" + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true }, "jsprim": { "version": "1.4.1", @@ -3031,16 +2945,6 @@ "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", "dev": true }, - "lazy-val": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.3.tgz", - "integrity": "sha512-pjCf3BYk+uv3ZcPzEVM0BFvO9Uw58TmlrU0oG5tTrr9Kcid3+kdKxapH8CjdYmVa2nO5wOoZn2rdvZx2PKj/xg==" - }, - "lazystream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", - "integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=" - }, "lcid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", @@ -3099,7 +3003,8 @@ "lodash": { "version": "4.17.4", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", + "dev": true }, "lodash._basecopy": { "version": "3.0.1", @@ -3233,11 +3138,6 @@ "integrity": "sha1-+zB4RHU7Zrnxr6VOJix0UwfbqOU=", "dev": true }, - "lodash.toarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz", - "integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE=" - }, "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", @@ -3407,7 +3307,8 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true }, "multipipe": { "version": "0.1.2", @@ -3432,11 +3333,6 @@ "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=", "dev": true }, - "node-emoji": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.8.1.tgz", - "integrity": "sha512-+ktMAh1Jwas+TnGodfCfjUbJKoANqPaJFN0z0iqh41eqD8dvguNzcitVSBSVK1pidz0AqGbLKcoVuVLRVZ/aVg==" - }, "node-gyp": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.6.2.tgz", @@ -3601,7 +3497,8 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=" + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true }, "orchestrator": { "version": "0.3.8", @@ -4128,16 +4025,6 @@ "integrity": "sha1-PnZyPjjf3aE8mx0poeB//uSzC1c=", "dev": true }, - "sanitize-filename": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.1.tgz", - "integrity": "sha1-YS2hyWRz+gLczaktzVtKsWSmdyo=" - }, - "sanitize-xml-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/sanitize-xml-string/-/sanitize-xml-string-1.1.0.tgz", - "integrity": "sha512-RzX25K64YtZm9FvdZr/Ac7Eeq0va1YX0xmpOkjWoREhgKXXldrJRVJhBel83nS8omIcaKcNTdLY8XzOIK920HA==" - }, "sass-graph": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", @@ -4147,7 +4034,8 @@ "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true }, "scss-tokenizer": { "version": "0.2.3", @@ -4166,7 +4054,8 @@ "semver": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true }, "send": { "version": "0.16.1", @@ -4304,22 +4193,12 @@ "integrity": "sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc=", "dev": true }, - "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.13.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", "dev": true }, - "stat-mode": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-0.2.2.tgz", - "integrity": "sha1-5sgLYjEj19gM8TLOU480YokHJQI=" - }, "statuses": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", @@ -4400,7 +4279,8 @@ "supports-color": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", - "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=" + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true }, "sw-toolbox": { "version": "3.6.0", @@ -4424,16 +4304,6 @@ "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", "dev": true }, - "tar-stream": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.5.5.tgz", - "integrity": "sha512-mQdgLPc/Vjfr3VWqWbfxW8yQNiJCbAZ+Gf6GDu1Cy0bdb33ofyiNGBtAY96jHFhDuivCwgW1H9DgTON+INiXgg==" - }, - "temp-file": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.0.0.tgz", - "integrity": "sha512-WaSZQMckvo975nF1fSv05Nuya63AiLtyn0oYURF1xw1BF092CpIXgRv/Y0vQeocL5pv4ouVsBOnTCoK4kAK2uQ==" - }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -4496,11 +4366,6 @@ "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", "dev": true }, - "truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=" - }, "ts-md5": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.2.2.tgz", @@ -4551,7 +4416,8 @@ "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=" + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true }, "tweetnacl": { "version": "0.14.5", @@ -4646,7 +4512,8 @@ "universalify": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", - "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=", + "dev": true }, "unpipe": { "version": "1.0.0", @@ -4674,11 +4541,6 @@ "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=", "dev": true }, - "utf8-byte-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", - "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" - }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", @@ -4707,7 +4569,8 @@ "uuid": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", - "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==", + "dev": true }, "v8flags": { "version": "2.1.1", @@ -4884,7 +4747,8 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "ws": { "version": "3.3.2", @@ -4892,11 +4756,6 @@ "integrity": "sha512-t+WGpsNxhMR4v6EClXS8r8km5ZljKJzyGhJf7goJz9k5Ye3+b5Bvno5rjqPuIBn5mnn5GBb7o8IrIWHxX1qOLQ==", "dev": true }, - "xml-escape": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", - "integrity": "sha1-OQTBQ/qOs6ADDsZG0pAqLxtwbEQ=" - }, "xml2js": { "version": "0.4.19", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", @@ -4912,7 +4771,8 @@ "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", - "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true }, "y18n": { "version": "3.2.1", @@ -4954,11 +4814,6 @@ } } }, - "zip-stream": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.2.0.tgz", - "integrity": "sha1-qLxF9MG0lpnGuQGYuqyqzbzUugQ=" - }, "zone.js": { "version": "0.8.18", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.8.18.tgz", diff --git a/package.json b/package.json index f628ed97e..66725cfa5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@angular/http": "5.0.0", "@angular/platform-browser": "5.0.0", "@angular/platform-browser-dynamic": "5.0.0", + "@ionic-native/camera": "^4.5.2", "@ionic-native/clipboard": "^4.3.2", "@ionic-native/core": "4.3.0", "@ionic-native/file": "^4.3.3", @@ -42,6 +43,7 @@ "@ionic-native/in-app-browser": "^4.3.3", "@ionic-native/keyboard": "^4.3.2", "@ionic-native/local-notifications": "^4.4.0", + "@ionic-native/media-capture": "^4.5.2", "@ionic-native/network": "^4.3.2", "@ionic-native/splash-screen": "4.3.0", "@ionic-native/sqlite": "^4.3.2", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 979d5f135..20b958fd6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -53,6 +53,8 @@ import { CoreEmulatorModule } from '../core/emulator/emulator.module'; import { CoreLoginModule } from '../core/login/login.module'; import { CoreMainMenuModule } from '../core/mainmenu/mainmenu.module'; import { CoreCoursesModule } from '../core/courses/courses.module'; +import { CoreFileUploaderModule } from '../core/fileuploader/fileuploader.module'; +import { CoreSharedFilesModule } from '../core/sharedfiles/sharedfiles.module'; // For translate loader. AoT requires an exported function for factories. @@ -82,6 +84,8 @@ export function createTranslateLoader(http: HttpClient) { CoreLoginModule, CoreMainMenuModule, CoreCoursesModule, + CoreFileUploaderModule, + CoreSharedFilesModule, CoreComponentsModule ], bootstrap: [IonicApp], diff --git a/src/app/app.scss b/src/app/app.scss index 3b5ab9aad..8c06237ba 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -274,3 +274,22 @@ ion-select { } } } + +// File uploader. +// ------------------------- + +.core-fileuploader-file-handler { + position: relative; + + input { + position: absolute; + top: 0; + right: 0; + min-width: 100%; + min-height: 100%; + opacity: 0; + outline: none; + z-index: 100; + cursor: pointer; + } +} diff --git a/src/classes/site.ts b/src/classes/site.ts index f24809ec9..b4645cfeb 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -717,9 +717,10 @@ export class CoreSite { * * @param {string} filePath File path. * @param {CoreWSFileUploadOptions} options File upload options. + * @param {Function} [onProgress] Function to call on progress. * @return {Promise} Promise resolved when uploaded. */ - uploadFile(filePath: string, options: CoreWSFileUploadOptions) : Promise { + uploadFile(filePath: string, options: CoreWSFileUploadOptions, onProgress?: (event: ProgressEvent) => any) : Promise { if (!options.fileArea) { options.fileArea = 'draft'; } @@ -727,7 +728,7 @@ export class CoreSite { return this.wsProvider.uploadFile(filePath, options, { siteUrl: this.siteUrl, wsToken: this.token - }); + }, onProgress); } /** @@ -1061,7 +1062,7 @@ export class CoreSite { } if (alertMessage) { - let alert = this.domUtils.showAlert('core.notice', alertMessage, undefined, 3000); + let alert = this.domUtils.showAlert(this.translate.instant('core.notice'), alertMessage, undefined, 3000); alert.onDidDismiss(() => { if (inApp) { resolve(this.utils.openInApp(url, options)); diff --git a/src/components/chrono/chrono.ts b/src/components/chrono/chrono.ts new file mode 100644 index 000000000..ee0d421bf --- /dev/null +++ b/src/components/chrono/chrono.ts @@ -0,0 +1,116 @@ +// (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, OnChanges, OnDestroy, Output, EventEmitter, SimpleChange, ChangeDetectorRef } from '@angular/core'; + +/** + * This component shows a chronometer in format HH:MM:SS. + * + * If no startTime is provided, it will start at 00:00:00. + * If an endTime is provided, the chrono will stop and emit an event in the onEnd output when that number of milliseconds is + * reached. E.g. if startTime=60000 and endTime=120000, the chrono will start at 00:01:00 and end when it reaches 00:02:00. + * + * This component has 2 boolean inputs to control the timer: running (to start and stop it) and reset. + * + * Example usage: + * + */ +@Component({ + selector: 'core-chrono', + template: '{{ time / 1000 | coreSecondsToHMS }}' +}) +export class CoreChronoComponent implements OnChanges, OnDestroy { + @Input() running: boolean; // Set it to true to start the chrono. Set it to false to stop it. + @Input() startTime?: number = 0; // Number of milliseconds to put in the chrono before starting. + @Input() endTime?: number; // Number of milliseconds to stop the chrono. + @Input() reset?: boolean; // Set it to true to reset the chrono. + @Output() onEnd?: EventEmitter; // Will emit an event when the endTime is reached. + + time: number = 0; + protected interval; + + constructor(private cdr: ChangeDetectorRef) { + this.onEnd = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.time = this.startTime || 0; + } + + /** + * Component being initialized. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes && changes.running) { + if (changes.running.currentValue) { + this.start(); + } else { + this.stop(); + } + } + if (changes && changes.reset && changes.reset.currentValue) { + this.resetChrono(); + } + } + + /** + * Reset the chrono, stopping it and setting it to startTime. + */ + protected resetChrono() : void { + this.stop(); + this.time = this.startTime || 0; + } + + /** + * Start the chrono if it isn't running. + */ + protected start() : void { + if (this.interval) { + // Already setup. + return; + } + + let lastExecTime = Date.now(); + + this.interval = setInterval(() => { + // Increase the chrono. + this.time += Date.now() - lastExecTime; + lastExecTime = Date.now(); + + if (typeof this.endTime != 'undefined' && this.time > this.endTime) { + // End time reached, stop the timer and call the end function. + this.stop(); + this.onEnd.emit(); + } + + // Force change detection. Angular doesn't detect these async operations. + this.cdr.detectChanges(); + }, 200); + } + + /** + * Stop the chrono, leaving the same time it has. + */ + protected stop() : void { + clearInterval(this.interval); + delete this.interval; + } + + ngOnDestroy() { + this.stop(); + } +} diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 8be44288c..a3d1ec8ba 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { IonicModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreDirectivesModule } from '../directives/directives.module'; +import { CorePipesModule } from '../pipes/pipes.module'; import { CoreLoadingComponent } from './loading/loading'; import { CoreMarkRequiredComponent } from './mark-required/mark-required'; import { CoreInputErrorsComponent } from './input-errors/input-errors'; @@ -28,6 +29,9 @@ import { CoreFileComponent } from './file/file'; import { CoreContextMenuComponent } from './context-menu/context-menu'; import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; +import { CoreChronoComponent } from './chrono/chrono'; +import { CoreLocalFileComponent } from './local-file/local-file'; +import { CoreSitePickerComponent } from './site-picker/site-picker'; @NgModule({ declarations: [ @@ -42,7 +46,10 @@ import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-pop CoreFileComponent, CoreContextMenuComponent, CoreContextMenuItemComponent, - CoreContextMenuPopoverComponent + CoreContextMenuPopoverComponent, + CoreChronoComponent, + CoreLocalFileComponent, + CoreSitePickerComponent ], entryComponents: [ CoreContextMenuPopoverComponent @@ -50,7 +57,8 @@ import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-pop imports: [ IonicModule, TranslateModule.forChild(), - CoreDirectivesModule + CoreDirectivesModule, + CorePipesModule ], exports: [ CoreLoadingComponent, @@ -63,7 +71,10 @@ import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-pop CoreSearchBoxComponent, CoreFileComponent, CoreContextMenuComponent, - CoreContextMenuItemComponent + CoreContextMenuItemComponent, + CoreChronoComponent, + CoreLocalFileComponent, + CoreSitePickerComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/file/file.html b/src/components/file/file.html index e4dab9efb..9cf0d41c6 100644 --- a/src/components/file/file.html +++ b/src/components/file/file.html @@ -1,4 +1,4 @@ - +

{{fileName}}

diff --git a/src/components/local-file/local-file.html b/src/components/local-file/local-file.html new file mode 100644 index 000000000..62eeafe76 --- /dev/null +++ b/src/components/local-file/local-file.html @@ -0,0 +1,29 @@ + + {{fileExtension}} + + +

+ {{fileName}} + + + +

+ + +
+ + +
+ + +

{{ size }}

+

{{ timemodified }}

+ +
+ +
+ diff --git a/src/components/local-file/local-file.ts b/src/components/local-file/local-file.ts new file mode 100644 index 000000000..1961a8064 --- /dev/null +++ b/src/components/local-file/local-file.ts @@ -0,0 +1,187 @@ +// (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, Output, OnInit, EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreFileProvider } from '../../providers/file'; +import { CoreDomUtilsProvider } from '../../providers/utils/dom'; +import { CoreMimetypeUtilsProvider } from '../../providers/utils/mimetype'; +import { CoreTextUtilsProvider } from '../../providers/utils/text'; +import { CoreUtilsProvider } from '../../providers/utils/utils'; +import * as moment from 'moment'; + +/** + * Component to handle a local file. Only files inside the app folder can be managed. + * + * Shows the file name, icon (depending on extension), size and time modified. + * Also, if managing is enabled it will also show buttons to rename and delete the file. + */ +@Component({ + selector: 'core-local-file', + templateUrl: 'local-file.html' +}) +export class CoreLocalFileComponent implements OnInit { + @Input() file: any; // A fileEntry retrieved using CoreFileProvider.getFile or similar. + @Input() manage?: boolean|string; // Whether the user can manage the file (edit and delete). + @Input() overrideClick?: boolean|string; // Whether the default item click should be overridden. + @Output() onDelete?: EventEmitter; // Will notify when the file is deleted. + @Output() onRename?: EventEmitter; // Will notify when the file is renamed. Receives the FileEntry as the param. + @Output() onClick?: EventEmitter; // Will notify when the file is clicked. Only if overrideClick is true. + + fileName: string; + fileIcon: string; + fileExtension: string; + size: string; + timemodified: string; + newFileName: string = ''; + editMode: boolean; + relativePath: string; + + constructor(private mimeUtils: CoreMimetypeUtilsProvider, private utils: CoreUtilsProvider, private translate: TranslateService, + private textUtils: CoreTextUtilsProvider, private fileProvider: CoreFileProvider, + private domUtils: CoreDomUtilsProvider) { + this.onDelete = new EventEmitter(); + this.onRename = new EventEmitter(); + this.onClick = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.manage = this.utils.isTrueOrOne(this.manage); + + // Let's calculate the relative path for the file. + this.relativePath = this.fileProvider.removeBasePath(this.file.toURL()); + if (!this.relativePath) { + // Didn't find basePath, use fullPath but if the user tries to manage the file it'll probably fail. + this.relativePath = this.file.fullPath; + } + + this.loadFileBasicData(); + + // Get the size and timemodified. + this.fileProvider.getMetadata(this.file).then((metadata) => { + if (metadata.size >= 0) { + this.size = this.textUtils.bytesToSize(metadata.size, 2); + } + + this.timemodified = moment(metadata.modificationTime).format('LLL'); + }); + } + + /** + * Load the basic data for the file. + * + * @param {[type]} scope [description] + * @param {[type]} file [description] + */ + protected loadFileBasicData() { + this.fileName = this.file.name; + this.fileIcon = this.mimeUtils.getFileIcon(this.file.name); + this.fileExtension = this.mimeUtils.getFileExtension(this.file.name); + } + + /** + * File clicked. + * + * @param {Event} e Click event. + */ + fileClicked(e: Event) : void { + e.preventDefault(); + e.stopPropagation(); + + if (this.utils.isTrueOrOne(this.overrideClick) && this.onClick.observers.length) { + this.onClick.emit(); + } else { + this.utils.openFile(this.file.toURL()); + } + }; + + /** + * Activate the edit mode. + * + * @param {Event} e Click event. + */ + activateEdit(e: Event) : void { + e.preventDefault(); + e.stopPropagation(); + this.editMode = true; + this.newFileName = this.file.name; + + // @todo For some reason core-auto-focus isn't working right. Focus the input manually. + // $timeout(function() { + // $mmUtil.focusElement(element[0].querySelector('input')); + // }); + }; + + /** + * Rename the file. + * + * @param {string} newName New name. + */ + changeName(newName: string) : void { + if (newName == this.file.name) { + // Name hasn't changed, stop. + this.editMode = false; + return; + } + + let modal = this.domUtils.showModalLoading(), + fileAndDir = this.fileProvider.getFileAndDirectoryFromPath(this.relativePath), + newPath = this.textUtils.concatenatePaths(fileAndDir.directory, newName); + + // Check if there's a file with this name. + this.fileProvider.getFile(newPath).then(() => { + // There's a file with this name, show error and stop. + this.domUtils.showErrorModal('core.errorfileexistssamename', true); + }).catch(() => { + // File doesn't exist, move it. + return this.fileProvider.moveFile(this.relativePath, newPath).then((fileEntry) => { + this.editMode = false; + this.file = fileEntry; + this.loadFileBasicData(); + this.onRename.emit({file: this.file}); + }).catch(() => { + this.domUtils.showErrorModal('core.errorrenamefile', true); + }); + }).finally(() => { + modal.dismiss(); + }); + }; + + /** + * Delete the file. + * + * @param {Event} e Click event. + */ + deleteFile(e: Event) : void { + e.preventDefault(); + e.stopPropagation(); + + // Ask confirmation. + this.domUtils.showConfirm(this.translate.instant('core.confirmdeletefile')).then(() => { + let modal = this.domUtils.showModalLoading(); + this.fileProvider.removeFile(this.relativePath).then(() => { + this.onDelete.emit(); + }).catch(() => { + this.domUtils.showErrorModal('core.errordeletefile', true); + }).finally(() => { + modal.dismiss(); + }); + }).catch(() => { + // User cancelled. + }); + } +} diff --git a/src/components/site-picker/site-picker.html b/src/components/site-picker/site-picker.html new file mode 100644 index 000000000..ee5fc727c --- /dev/null +++ b/src/components/site-picker/site-picker.html @@ -0,0 +1,6 @@ + + {{ 'core.site' | translate }} + + {{ site.fullNameAndSiteName }} + + diff --git a/src/components/site-picker/site-picker.ts b/src/components/site-picker/site-picker.ts new file mode 100644 index 000000000..1f4f92662 --- /dev/null +++ b/src/components/site-picker/site-picker.ts @@ -0,0 +1,66 @@ +// (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, Output, EventEmitter, OnInit } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '../../providers/sites'; +import { CoreTextUtilsProvider } from '../../providers/utils/text'; + +/** + * Component to display a site selector. It will display a select with the list of sites. If the selected site changes, + * an output will be emitted with the site ID. + * + * Example usage: + * + */ +@Component({ + selector: 'core-site-picker', + templateUrl: 'site-picker.html' +}) +export class CoreSitePickerComponent implements OnInit { + @Input() initialSite?: string; // Initial site. If not provided, current site. + @Output() siteSelected: EventEmitter; // Emit an event when a site is selected. Sends the siteId as parameter. + + selectedSite: string; + sites: any[]; + + constructor(private translate: TranslateService, private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider) { + this.siteSelected = new EventEmitter(); + } + + ngOnInit() { + this.selectedSite = this.initialSite || this.sitesProvider.getCurrentSiteId(); + + // Load the sites. + this.sitesProvider.getSites().then((sites) => { + let promises = []; + + sites.forEach((site: any) => { + // Format the site name. + promises.push(this.textUtils.formatText(site.siteName, true, true).catch(() => { + return site.siteName; + }).then((formatted) => { + site.fullNameAndSiteName = this.translate.instant('core.fullnameandsitename', + {fullname: site.fullName, sitename: formatted}); + })); + }); + + return Promise.all(promises).then(() => { + this.sites = sites; + }); + }); + } + +} diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index d4724b600..1701696d3 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -15,6 +15,8 @@ import { NgModule } from '@angular/core'; import { Platform } from 'ionic-angular'; +// Ionic Native services. +import { Camera } from '@ionic-native/camera'; import { Clipboard } from '@ionic-native/clipboard'; import { File } from '@ionic-native/file'; import { FileTransfer } from '@ionic-native/file-transfer'; @@ -22,22 +24,27 @@ import { Globalization } from '@ionic-native/globalization'; import { InAppBrowser } from '@ionic-native/in-app-browser'; import { Keyboard } from '@ionic-native/keyboard'; import { LocalNotifications } from '@ionic-native/local-notifications'; +import { MediaCapture } from '@ionic-native/media-capture'; import { Network } from '@ionic-native/network'; import { SplashScreen } from '@ionic-native/splash-screen'; import { StatusBar } from '@ionic-native/status-bar'; import { SQLite } from '@ionic-native/sqlite'; import { Zip } from '@ionic-native/zip'; +// Services that Mock Ionic Native in browser an desktop. +import { CameraMock } from './providers/camera'; import { ClipboardMock } from './providers/clipboard'; import { FileMock } from './providers/file'; import { FileTransferMock } from './providers/file-transfer'; import { GlobalizationMock } from './providers/globalization'; import { InAppBrowserMock } from './providers/inappbrowser'; import { LocalNotificationsMock } from './providers/local-notifications'; +import { MediaCaptureMock } from './providers/media-capture'; import { NetworkMock } from './providers/network'; import { ZipMock } from './providers/zip'; import { CoreEmulatorHelperProvider } from './providers/helper'; +import { CoreEmulatorCaptureHelperProvider } from './providers/capture-helper'; import { CoreAppProvider } from '../../providers/app'; import { CoreFileProvider } from '../../providers/file'; import { CoreTextUtilsProvider } from '../../providers/utils/text'; @@ -46,6 +53,15 @@ import { CoreUrlUtilsProvider } from '../../providers/utils/url'; import { CoreUtilsProvider } from '../../providers/utils/utils'; import { CoreInitDelegate } from '../../providers/init'; +/** + * This module handles the emulation of Cordova plugins in browser and desktop. + * + * It includes the "mock" of all the Ionic Native services that should be supported in browser and desktop, + * otherwise those features would only work in a Cordova environment. + * + * This module also determines if the app should use the original service or the mock. In each of the "useFactory" + * functions we check if the app is running in mobile or not, and then provide the right service to use. + */ @NgModule({ declarations: [ ], @@ -53,6 +69,14 @@ import { CoreInitDelegate } from '../../providers/init'; ], providers: [ CoreEmulatorHelperProvider, + CoreEmulatorCaptureHelperProvider, + { + provide: Camera, + deps: [CoreAppProvider, CoreEmulatorCaptureHelperProvider], + useFactory: (appProvider: CoreAppProvider, captureHelper: CoreEmulatorCaptureHelperProvider) => { + return appProvider.isMobile() ? new Camera() : new CameraMock(captureHelper); + } + }, { provide: Clipboard, deps: [CoreAppProvider], @@ -99,6 +123,13 @@ import { CoreInitDelegate } from '../../providers/init'; return appProvider.isMobile() ? new LocalNotifications() : new LocalNotificationsMock(appProvider, utils); } }, + { + provide: MediaCapture, + deps: [CoreAppProvider, CoreEmulatorCaptureHelperProvider], + useFactory: (appProvider: CoreAppProvider, captureHelper: CoreEmulatorCaptureHelperProvider) => { + return appProvider.isMobile() ? new MediaCapture() : new MediaCaptureMock(captureHelper); + } + }, { provide: Network, deps: [Platform], diff --git a/src/core/emulator/pages/capture-media/capture-media.html b/src/core/emulator/pages/capture-media/capture-media.html new file mode 100644 index 000000000..a6fc6dbe1 --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.html @@ -0,0 +1,55 @@ + + + + + + + {{ title }} + + + + + + + + +
+ + + + + + + + + {{ 'core.capturedimage' | translate }} + + +
+ + +
+
+
+
+ + + + + + + + + + + + + + diff --git a/src/core/emulator/pages/capture-media/capture-media.module.ts b/src/core/emulator/pages/capture-media/capture-media.module.ts new file mode 100644 index 000000000..133671a75 --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.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 { CoreEmulatorCaptureMediaPage } from './capture-media'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; + +@NgModule({ + declarations: [ + CoreEmulatorCaptureMediaPage + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(CoreEmulatorCaptureMediaPage), + TranslateModule.forChild() + ] +}) +export class CoreEmulatorCaptureMediaPageModule {} diff --git a/src/core/emulator/pages/capture-media/capture-media.scss b/src/core/emulator/pages/capture-media/capture-media.scss new file mode 100644 index 000000000..58bb5d1a2 --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.scss @@ -0,0 +1,68 @@ +page-core-emulator-capture-media { + ion-content { + .core-av-wrapper { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: 0; + padding: 0; + clear: both; + + .core-webcam-image-canvas { + display: none; + } + + .core-audio-record-container { + width: 100%; + height: 100%; + + .core-audio-canvas { + width: 100%; + height: 100%; + } + + .core-audio-captured { + width: 100%; + } + } + + audio, video, img { + width: 100%; + height: 100%; + display: table-cell; + text-align: center; + vertical-align: middle; + object-fit: contain; + + &.core-webcam-stream { + -webkit-transform: scaleX(-1); + transform: scaleX(-1); + } + } + } + + .scroll-content { + // We're modifying the height of the footer, the padding-bottom of the scroll needs to change too. + margin-bottom: 44px !important; + } + } + + ion-footer { + background-color: $gray; + border-top: 1px solid $gray-dark; + + .col { + padding: 0; + + .icon.ion-md-trash, .icon.ion-ios-trash { + color: $red; + } + } + + .chrono-container { + line-height: 24px; + } + } +} diff --git a/src/core/emulator/pages/capture-media/capture-media.ts b/src/core/emulator/pages/capture-media/capture-media.ts new file mode 100644 index 000000000..ba257c29f --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.ts @@ -0,0 +1,402 @@ +// (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, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; +import { CoreFileProvider } from '../../../../providers/file'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreTimeUtilsProvider } from '../../../../providers/utils/time'; + +/** + * Page to capture media in browser or desktop. + */ +@IonicPage() +@Component({ + selector: 'page-core-emulator-capture-media', + templateUrl: 'capture-media.html', +}) +export class CoreEmulatorCaptureMediaPage implements OnInit, OnDestroy { + @ViewChild('streamVideo') streamVideo: ElementRef; + @ViewChild('previewVideo') previewVideo: ElementRef; + @ViewChild('imgCanvas') imgCanvas: ElementRef; + @ViewChild('previewImage') previewImage: ElementRef; + @ViewChild('streamAudio') streamAudio: ElementRef; + @ViewChild('previewAudio') previewAudio: ElementRef; + + title: string; // The title of the page. + isAudio: boolean; // Whether it should capture audio. + isVideo: boolean; // Whether it should capture video. + isImage: boolean; // Whether it should capture image. + readyToCapture: boolean; // Whether it's ready to capture. + hasCaptured: boolean; // Whether it has captured something. + isCapturing: boolean; // Whether it's capturing. + maxTime: number; // The max time to capture. + resetChrono: boolean; // Boolean to reset the chrono. + + protected type: string; // The type to capture: audio, video, image, captureimage. + protected isCaptureImage: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera). + protected returnDataUrl: boolean; // Whether it should return a data img. Only if isImage. + protected facingMode: string; // Camera facing mode. + protected mimetype: string; + protected extension: string; + protected window: any; // Cast window to "any" because some of the properties used aren't in the window spec. + protected mediaRecorder; // To record video/audio. + protected audioDrawer; // To start/stop the display of audio sound. + protected quality; // Image only. + protected previewMedia: HTMLAudioElement|HTMLVideoElement; // The element to preview the audio/video captured. + protected mediaBlob: Blob; // A Blob where the captured data is stored. + protected localMediaStream: MediaStream; + + constructor(private viewCtrl: ViewController, params: NavParams, private domUtils: CoreDomUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, private fileProvider: CoreFileProvider, + private textUtils: CoreTextUtilsProvider, private cdr: ChangeDetectorRef) { + this.window = window; + this.type = params.get('type'); + this.maxTime = params.get('maxTime'); + this.facingMode = params.get('facingMode') || 'environment'; + this.mimetype = params.get('mimetype'); + this.extension = params.get('extension'); + this.quality = params.get('quality') || 0.92; + this.returnDataUrl = !!params.get('returnDataUrl'); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.initVariables(); + + let constraints = { + video: this.isAudio ? false : {facingMode: this.facingMode}, + audio: !this.isImage + }; + + navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + let chunks = []; + this.localMediaStream = stream; + + if (!this.isImage) { + if (this.isVideo) { + this.previewMedia = this.previewVideo.nativeElement; + } else { + this.previewMedia = this.previewAudio.nativeElement; + this.initAudioDrawer(this.localMediaStream); + this.audioDrawer.start(); + } + + this.mediaRecorder = new this.window.MediaRecorder(this.localMediaStream, {mimeType: this.mimetype}); + + // When video or audio is recorded, add it to the list of chunks. + this.mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + // When recording stops, create a Blob element with the recording and set it to the video or audio. + this.mediaRecorder.onstop = () => { + this.mediaBlob = new Blob(chunks); + chunks = []; + + this.previewMedia.src = window.URL.createObjectURL(this.mediaBlob); + }; + } + + if (this.isImage || this.isVideo) { + let hasLoaded = false, + waitTimeout; + + // Listen for stream ready to display the stream. + this.streamVideo.nativeElement.onloadedmetadata = () => { + if (hasLoaded) { + // Already loaded or timeout triggered, stop. + return; + } + + hasLoaded = true; + clearTimeout(waitTimeout); + this.readyToCapture = true; + this.streamVideo.nativeElement.onloadedmetadata = null; + // Force change detection. Angular doesn't detect these async operations. + this.cdr.detectChanges(); + }; + + // Set the stream as the source of the video. + this.streamVideo.nativeElement.src = window.URL.createObjectURL(this.localMediaStream); + + // If stream isn't ready in a while, show error. + waitTimeout = setTimeout(() => { + if (!hasLoaded) { + // Show error. + hasLoaded = true; + this.dismissWithError(-1, 'Cannot connect to webcam.'); + } + }, 10000); + } else { + // It's ready to capture. + this.readyToCapture = true; + } + }).catch((error) => { + this.dismissWithError(-1, error.message || error); + }); + } + + /** + * Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording: + * https://github.com/mdn/web-dictaphone + * + * @param {MediaStream} stream Stream returned by getUserMedia. + */ + protected initAudioDrawer(stream: MediaStream) : void { + let audioCtx = new (this.window.AudioContext || this.window.webkitAudioContext)(), + canvasCtx = this.streamAudio.nativeElement.getContext('2d'), + source = audioCtx.createMediaStreamSource(stream), + analyser = audioCtx.createAnalyser(), + bufferLength = analyser.frequencyBinCount, + dataArray = new Uint8Array(bufferLength), + width = this.streamAudio.nativeElement.width, + height = this.streamAudio.nativeElement.height, + running = false, + skip = true, + drawAudio = () => { + if (!running) { + return; + } + + // Update the draw every animation frame. + requestAnimationFrame(drawAudio); + + // Skip half of the frames to improve performance, shouldn't affect the smoothness. + skip = !skip; + if (skip) { + return; + } + + let sliceWidth = width / bufferLength, + x = 0; + + analyser.getByteTimeDomainData(dataArray); + + canvasCtx.fillStyle = 'rgb(200, 200, 200)'; + canvasCtx.fillRect(0, 0, width, height); + + canvasCtx.lineWidth = 1; + canvasCtx.strokeStyle = 'rgb(0, 0, 0)'; + + canvasCtx.beginPath(); + + for (let i = 0; i < bufferLength; i++) { + let v = dataArray[i] / 128.0, + y = v * height / 2; + + if (i === 0) { + canvasCtx.moveTo(x, y); + } else { + canvasCtx.lineTo(x, y); + } + + x += sliceWidth; + } + + canvasCtx.lineTo(width, height / 2); + canvasCtx.stroke(); + }; + + analyser.fftSize = 2048; + source.connect(analyser); + + this.audioDrawer = { + start: () => { + if (running) { + return; + } + + running = true; + drawAudio(); + }, + stop: () => { + running = false; + } + }; + } + + /** + * Initialize some variables based on the params. + */ + protected initVariables() { + if (this.type == 'captureimage') { + this.isCaptureImage = true; + this.type = 'image'; + } + + // Initialize some data based on the type of media to capture. + if (this.type == 'video') { + this.isVideo = true; + this.title = 'core.capturevideo'; + } else if (this.type == 'audio') { + this.isAudio = true; + this.title = 'core.captureaudio'; + } else if (this.type == 'image') { + this.isImage = true; + this.title = 'core.captureimage'; + } + } + + /** + * Main action clicked: record or stop recording. + */ + actionClicked() : void { + if (this.isCapturing) { + // It's capturing, stop. + this.stopCapturing(); + this.cdr.detectChanges(); + } else { + if (!this.isImage) { + // Start the capture. + this.isCapturing = true; + this.resetChrono = false; + this.mediaRecorder.start(); + this.cdr.detectChanges(); + } else { + // Get the image from the video and set it to the canvas, using video width/height. + let width = this.streamVideo.nativeElement.videoWidth, + height = this.streamVideo.nativeElement.videoHeight; + + this.imgCanvas.nativeElement.width = width; + this.imgCanvas.nativeElement.height = height; + this.imgCanvas.nativeElement.getContext('2d').drawImage(this.streamVideo.nativeElement, 0, 0, width, height); + + // Convert the image to blob and show it in an image element. + let loadingModal = this.domUtils.showModalLoading(); + this.imgCanvas.nativeElement.toBlob((blob) => { + loadingModal.dismiss(); + + this.mediaBlob = blob; + this.previewImage.nativeElement.setAttribute('src', window.URL.createObjectURL(this.mediaBlob)); + this.hasCaptured = true; + }, this.mimetype, this.quality); + } + } + } + + /** + * User cancelled. + */ + cancel() : void { + // Send a "cancelled" error like the Cordova plugin does. + this.dismissWithError(3, 'Canceled.', 'Camera cancelled'); + } + + /** + * Discard the captured media. + */ + discard() : void { + this.previewMedia && this.previewMedia.pause(); + this.streamVideo && this.streamVideo.nativeElement.play(); + this.audioDrawer && this.audioDrawer.start(); + + this.hasCaptured = false; + this.isCapturing = false; + this.resetChrono = true; + delete this.mediaBlob; + this.cdr.detectChanges(); + }; + + /** + * Close the modal, returning some data (success). + * + * @param {any} data Data to return. + */ + dismissWithData(data: any) : void { + this.viewCtrl.dismiss(data, 'success'); + } + + /** + * Close the modal, returning an error. + * + * @param {number} code Error code. Will not be used if it's a Camera capture. + * @param {string} message Error message. + * @param {string} [cameraMessage] A specific message to use if it's a Camera capture. If not set, message will be used. + */ + dismissWithError(code: number, message: string, cameraMessage?: string) : void { + let isCamera = this.isImage && !this.isCaptureImage, + error = isCamera ? (cameraMessage || message) : {code: code, message: message}; + this.viewCtrl.dismiss(error, 'error'); + } + + /** + * Done capturing, write the file. + */ + done() : void { + if (this.returnDataUrl) { + // Return the image as a base64 string. + this.dismissWithData(this.imgCanvas.nativeElement.toDataURL(this.mimetype, this.quality)); + return; + } + + if (!this.mediaBlob) { + // Shouldn't happen. + this.domUtils.showErrorModal('Please capture the media first.'); + return; + } + + // Create the file and return it. + let fileName = this.type + '_' + this.timeUtils.readableTimestamp() + '.' + this.extension, + path = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, 'media/' + fileName); + + let loadingModal = this.domUtils.showModalLoading(); + + this.fileProvider.writeFile(path, this.mediaBlob).then((fileEntry) => { + if (this.isImage && !this.isCaptureImage) { + this.dismissWithData(fileEntry.toURL()); + } else { + // The capture plugin returns a MediaFile, not a FileEntry. The only difference is that + // it supports a new function that won't be supported in desktop. + fileEntry.getFormatData = (successFn, errorFn) => {}; + + this.dismissWithData([fileEntry]); + } + }).catch((err) => { + this.domUtils.showErrorModal(err); + }).finally(() => { + loadingModal.dismiss(); + }); + }; + + /** + * Stop capturing. Only for video and audio. + */ + stopCapturing() : void { + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.audioDrawer && this.audioDrawer.stop(); + this.mediaRecorder && this.mediaRecorder.stop(); + this.isCapturing = false; + this.hasCaptured = true; + }; + + /** + * Page destroyed. + */ + ngOnDestroy() : void { + const tracks = this.localMediaStream.getTracks(); + tracks.forEach((track) => { + track.stop(); + }); + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.previewMedia && this.previewMedia.pause(); + this.audioDrawer && this.audioDrawer.stop(); + delete this.mediaBlob; + } +} \ No newline at end of file diff --git a/src/core/emulator/providers/camera.ts b/src/core/emulator/providers/camera.ts new file mode 100644 index 000000000..681fe1ca6 --- /dev/null +++ b/src/core/emulator/providers/camera.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 { Camera, CameraOptions } from '@ionic-native/camera'; +import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; + +/** + * Emulates the Cordova Camera plugin in desktop apps and in browser. + */ +@Injectable() +export class CameraMock extends Camera { + + constructor(private captureHelper: CoreEmulatorCaptureHelperProvider) { + super(); + } + + /** + * Remove intermediate image files that are kept in temporary storage after calling camera.getPicture. + * + * @return {Promise} Promise resolved when done. + */ + cleanup() : Promise { + // iOS only, nothing to do. + return Promise.resolve(); + } + + /** + * Take a picture. + * + * @param {CameraOptions} options Options that you want to pass to the camera. + * @return {Promise} Promise resolved when captured. + */ + getPicture(options: CameraOptions) : Promise { + return this.captureHelper.captureMedia('image', options); + } +} diff --git a/src/core/emulator/providers/capture-helper.ts b/src/core/emulator/providers/capture-helper.ts new file mode 100644 index 000000000..3d03605ba --- /dev/null +++ b/src/core/emulator/providers/capture-helper.ts @@ -0,0 +1,222 @@ +// (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 { ModalController, Modal } from 'ionic-angular'; +import { CoreMimetypeUtilsProvider } from '../../../providers/utils/mimetype'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; + +/** + * Helper service with some features to capture media (image, audio, video). + */ +@Injectable() +export class CoreEmulatorCaptureHelperProvider { + protected possibleAudioMimeTypes = { + 'audio/webm': 'weba', + 'audio/ogg': 'ogg' + }; + protected possibleVideoMimeTypes = { + 'video/webm;codecs=vp9': 'webm', + 'video/webm;codecs=vp8': 'webm', + 'video/ogg': 'ogv' + }; + protected win: any; + videoMimeType: string; + audioMimeType: string; + + constructor(private utils: CoreUtilsProvider, private mimeUtils: CoreMimetypeUtilsProvider, + private modalCtrl: ModalController) { + // Convert the window to "any" type because some of the variables used (like MediaRecorder) aren't in the window spec. + this.win = window; + } + + /** + * Capture media (image, audio, video). + * + * @param {String} type Type of media: image, audio, video. + * @param {Function} successCallback Function called when media taken. + * @param {Function} errorCallback Function called when error or cancel. + * @param {Object} [options] Optional options. + * @return {Void} + */ + captureMedia(type: string, options: any) : Promise { + options = options || {}; + + try { + // Build the params to send to the modal. + let deferred = this.utils.promiseDefer(), + params: any = { + type: type + }, + mimeAndExt, + modal: Modal; + + // Initialize some data based on the type of media to capture. + if (type == 'video') { + mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'audio') { + mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'image') { + if (typeof options.sourceType != 'undefined' && options.sourceType != 1) { + return Promise.reject('This source type is not supported in desktop.'); + } + + if (options.cameraDirection == 1) { + params.facingMode = 'user'; + } + + if (options.encodingType == 1) { + params.mimetype = 'image/png'; + params.extension = 'png'; + } else { + params.mimetype = 'image/jpeg'; + params.extension = 'jpeg'; + } + + if (options.quality >= 0 && options.quality <= 100) { + params.quality = options.quality / 100; + } + + if (options.destinationType == 0) { + params.returnDataUrl = true; + } + } + + if (options.duration) { + params.maxTime = options.duration * 1000; + } + + modal = this.modalCtrl.create('CoreEmulatorCaptureMediaPage', params); + modal.present(); + modal.onDidDismiss((data: any, role: string) => { + if (role == 'success') { + deferred.resolve(data); + } else { + deferred.reject(data); + } + }); + + return deferred.promise; + } catch(ex) { + return Promise.reject(ex.toString()); + } + } + + /** + * Get the mimetype and extension to capture media. + * + * @param {string} type Type of media: image, audio, video. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {{extension: string, mimetype: string}} An object with mimetype and extension to use. + */ + protected getMimeTypeAndExtension(type: string, mimetypes) : {extension: string, mimetype: string} { + var result: any = {}; + + if (mimetypes && mimetypes.length) { + // Search for a supported mimetype. + for (let i = 0; i < mimetypes.length; i++) { + let mimetype = mimetypes[i], + matches = mimetype.match(new RegExp('^' + type + '/')); + + if (matches && matches.length && this.win.MediaRecorder.isTypeSupported(mimetype)) { + result.mimetype = mimetype; + break; + } + } + } + + if (result.mimetype) { + // Found a supported mimetype in the mimetypes array, get the extension. + result.extension = this.mimeUtils.getExtension(result.mimetype); + } else if (type == 'video') { + // No mimetype found, use default extension. + result.mimetype = this.videoMimeType; + result.extension = this.possibleVideoMimeTypes[result.mimetype]; + } else if (type == 'audio') { + // No mimetype found, use default extension. + result.mimetype = this.audioMimeType; + result.extension = this.possibleAudioMimeTypes[result.mimetype]; + } + + return result; + } + + /** + * Init the getUserMedia function, using a deprecated function as fallback if the new one doesn't exist. + * + * @return {boolean} Whether the function is supported. + */ + protected initGetUserMedia() : boolean { + let nav = navigator; + // Check if there is a function to get user media. + if (typeof nav.mediaDevices == 'undefined') { + nav.mediaDevices = {}; + } + + if (!nav.mediaDevices.getUserMedia) { + // New function doesn't exist, check if the deprecated function is supported. + nav.getUserMedia = nav.getUserMedia || nav.webkitGetUserMedia || nav.mozGetUserMedia || nav.msGetUserMedia; + + if (nav.getUserMedia) { + // Deprecated function exists, support the new function using the deprecated one. + navigator.mediaDevices.getUserMedia = (constraints) => { + let deferred = this.utils.promiseDefer(); + nav.getUserMedia(constraints, deferred.resolve, deferred.reject); + return deferred.promise; + }; + } else { + return false; + } + } + + return true; + } + + /** + * Initialize the mimetypes to use when capturing. + */ + protected initMimeTypes() : void { + // Determine video and audio mimetype to use. + for (let mimeType in this.possibleVideoMimeTypes) { + if (this.win.MediaRecorder.isTypeSupported(mimeType)) { + this.videoMimeType = mimeType; + break; + } + } + + for (let mimeType in this.possibleAudioMimeTypes) { + if (this.win.MediaRecorder.isTypeSupported(mimeType)) { + this.audioMimeType = mimeType; + break; + } + } + } + + /** + * Load the Mocks that need it. + * + * @return {Promise} Promise resolved when loaded. + */ + load() : Promise { + if (typeof this.win.MediaRecorder != 'undefined' && this.initGetUserMedia()) { + this.initMimeTypes(); + } + + return Promise.resolve(); + } +} diff --git a/src/core/emulator/providers/file-transfer.ts b/src/core/emulator/providers/file-transfer.ts index 4ce1da533..e6c0e193e 100644 --- a/src/core/emulator/providers/file-transfer.ts +++ b/src/core/emulator/providers/file-transfer.ts @@ -296,7 +296,7 @@ export class FileTransferObjectMock extends FileTransferObject { } } - (xhr).onprogress = (xhr, ev) => { + xhr.onprogress = (ev: ProgressEvent) : any => { if (this.progressListener) { this.progressListener(ev); } diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts index abfd542fa..1a6e2fa86 100644 --- a/src/core/emulator/providers/helper.ts +++ b/src/core/emulator/providers/helper.ts @@ -19,9 +19,10 @@ import { File } from '@ionic-native/file'; import { LocalNotifications } from '@ionic-native/local-notifications'; import { CoreInitDelegate, CoreInitHandler } from '../../../providers/init'; import { FileTransferErrorMock } from './file-transfer'; +import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; /** - * Emulates the Cordova Zip plugin in desktop apps and in browser. + * Helper service for the emulator feature. It also acts as an init handler. */ @Injectable() export class CoreEmulatorHelperProvider implements CoreInitHandler { @@ -30,7 +31,8 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { blocking = true; constructor(private file: File, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, - initDelegate: CoreInitDelegate, private localNotif: LocalNotifications) {} + initDelegate: CoreInitDelegate, private localNotif: LocalNotifications, + private captureHelper: CoreEmulatorCaptureHelperProvider) {} /** * Load the Mocks that need it. @@ -44,6 +46,7 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { this.fileProvider.setHTMLBasePath(basePath); })); promises.push((this.localNotif).load()); + promises.push(this.captureHelper.load()); (window).FileTransferError = FileTransferErrorMock; diff --git a/src/core/emulator/providers/media-capture.ts b/src/core/emulator/providers/media-capture.ts new file mode 100644 index 000000000..55a8b65ce --- /dev/null +++ b/src/core/emulator/providers/media-capture.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 } from '@angular/core'; +import { MediaCapture, CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions } from '@ionic-native/media-capture'; +import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; + +/** + * Emulates the Cordova MediaCapture plugin in desktop apps and in browser. + */ +@Injectable() +export class MediaCaptureMock extends MediaCapture { + + constructor(private captureHelper: CoreEmulatorCaptureHelperProvider) { + super(); + } + + /** + * Start the audio recorder application and return information about captured audio clip files. + * + * @param {CaptureAudioOptions} options Options. + * @return {Promise} Promise resolved when captured. + */ + captureAudio(options: CaptureAudioOptions) : Promise { + return this.captureHelper.captureMedia('audio', options); + } + + /** + * Start the camera application and return information about captured image files. + * + * @param {CaptureImageOptions} options Options. + * @return {Promise} Promise resolved when captured. + */ + captureImage(options: CaptureImageOptions) : Promise { + return this.captureHelper.captureMedia('captureimage', options); + } + + /** + * Start the video recorder application and return information about captured video clip files. + * + * @param {CaptureVideoOptions} options Options. + * @return {Promise} Promise resolved when captured. + */ + captureVideo(options: CaptureVideoOptions) : Promise { + return this.captureHelper.captureMedia('video', options); + } +} diff --git a/src/core/fileuploader/fileuploader.module.ts b/src/core/fileuploader/fileuploader.module.ts new file mode 100644 index 000000000..5d52372bb --- /dev/null +++ b/src/core/fileuploader/fileuploader.module.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 { NgModule } from '@angular/core'; +import { CoreFileUploaderProvider } from './providers/fileuploader'; +import { CoreFileUploaderHelperProvider } from './providers/helper'; +import { CoreFileUploaderDelegate } from './providers/delegate'; +import { CoreFileUploaderAlbumHandler } from './providers/album-handler'; +import { CoreFileUploaderAudioHandler } from './providers/audio-handler'; +import { CoreFileUploaderCameraHandler } from './providers/camera-handler'; +import { CoreFileUploaderFileHandler } from './providers/file-handler'; +import { CoreFileUploaderVideoHandler } from './providers/video-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + CoreFileUploaderProvider, + CoreFileUploaderHelperProvider, + CoreFileUploaderDelegate, + CoreFileUploaderAlbumHandler, + CoreFileUploaderAudioHandler, + CoreFileUploaderCameraHandler, + CoreFileUploaderFileHandler, + CoreFileUploaderVideoHandler + ] +}) +export class CoreFileUploaderModule { + constructor(delegate: CoreFileUploaderDelegate, albumHandler: CoreFileUploaderAlbumHandler, + audioHandler: CoreFileUploaderAudioHandler, cameraHandler: CoreFileUploaderCameraHandler, + videoHandler: CoreFileUploaderVideoHandler, fileHandler: CoreFileUploaderFileHandler) { + delegate.registerHandler(albumHandler); + delegate.registerHandler(audioHandler); + delegate.registerHandler(cameraHandler); + delegate.registerHandler(fileHandler); + delegate.registerHandler(videoHandler); + } +} diff --git a/src/core/fileuploader/lang/en.json b/src/core/fileuploader/lang/en.json new file mode 100644 index 000000000..340786594 --- /dev/null +++ b/src/core/fileuploader/lang/en.json @@ -0,0 +1,28 @@ +{ + "addfiletext": "Add file", + "audio": "Audio", + "camera": "Camera", + "confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?", + "confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?", + "errorcapturingaudio": "Error capturing audio.", + "errorcapturingimage": "Error capturing image.", + "errorcapturingvideo": "Error capturing video.", + "errorgettingimagealbum": "Error getting image from album.", + "errormustbeonlinetoupload": "You have to be online to upload files.", + "errornoapp": "You don't have an app installed to perform this action.", + "errorreadingfile": "Error reading file.", + "errorwhileuploading": "An error occurred during the file upload.", + "file": "File", + "fileuploaded": "The file was successfully uploaded.", + "filesofthesetypes": "Accepted file types:", + "invalidfiletype": "{{$a}} filetype cannot be accepted.", + "maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.", + "more": "More", + "photoalbums": "Photo albums", + "readingfile": "Reading file", + "selectafile": "Select a file", + "uploadafile": "Upload a file", + "uploading": "Uploading", + "uploadingperc": "Uploading: {{$a}}%", + "video": "Video" +} \ No newline at end of file diff --git a/src/core/fileuploader/providers/album-handler.ts b/src/core/fileuploader/providers/album-handler.ts new file mode 100644 index 000000000..3e78e3e2d --- /dev/null +++ b/src/core/fileuploader/providers/album-handler.ts @@ -0,0 +1,71 @@ +// (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 { CoreAppProvider } from '../../../providers/app'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from './delegate'; +import { CoreFileUploaderHelperProvider } from './helper'; +/** + * Handler to upload files from the album. + */ +@Injectable() +export class CoreFileUploaderAlbumHandler implements CoreFileUploaderHandler { + name = 'CoreFileUploaderAlbum'; + priority = 2000; + + constructor(private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, + private uploaderHelper: CoreFileUploaderHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise { + return this.appProvider.isMobile(); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[] { + // Album allows picking images and videos. + return this.utils.filterByRegexp(mimetypes, /^(image|video)\//); + } + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.photoalbums', + class: 'core-fileuploader-album-handler', + icon: 'images', + action: (maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]) => { + return this.uploaderHelper.uploadImage(true, maxSize, upload, mimetypes).then((result) => { + return { + treated: true, + result: result + }; + }); + } + }; + } +} diff --git a/src/core/fileuploader/providers/audio-handler.ts b/src/core/fileuploader/providers/audio-handler.ts new file mode 100644 index 000000000..332e8d0ee --- /dev/null +++ b/src/core/fileuploader/providers/audio-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 { Platform } from 'ionic-angular'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from './delegate'; +import { CoreFileUploaderHelperProvider } from './helper'; +/** + * Handler to record an audio to upload it. + */ +@Injectable() +export class CoreFileUploaderAudioHandler implements CoreFileUploaderHandler { + name = 'CoreFileUploaderAudio'; + priority = 1600; + + constructor(private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, private platform: Platform, + private uploaderHelper: CoreFileUploaderHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise { + return this.appProvider.isMobile() || (this.appProvider.canGetUserMedia() && this.appProvider.canRecordMedia()); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[] { + if (this.platform.is('ios')) { + // iOS records as WAV. + return this.utils.filterByRegexp(mimetypes, /^audio\/wav$/); + } else if (this.platform.is('android')) { + // In Android we don't know the format the audio will be recorded, so accept any audio mimetype. + return this.utils.filterByRegexp(mimetypes, /^audio\//); + } else { + // In desktop, support audio formats that are supported by MediaRecorder. + let mediaRecorder = (window).MediaRecorder; + if (mediaRecorder) { + return mimetypes.filter((type) => { + let matches = type.match(/^audio\//); + return matches && matches.length && mediaRecorder.isTypeSupported(type); + }); + } + } + + return []; + } + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.audio', + class: 'core-fileuploader-audio-handler', + icon: 'microphone', + action: (maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]) => { + return this.uploaderHelper.uploadAudioOrVideo(true, maxSize, upload, mimetypes).then((result) => { + return { + treated: true, + result: result + }; + }); + } + }; + } +} diff --git a/src/core/fileuploader/providers/camera-handler.ts b/src/core/fileuploader/providers/camera-handler.ts new file mode 100644 index 000000000..e72adb6f4 --- /dev/null +++ b/src/core/fileuploader/providers/camera-handler.ts @@ -0,0 +1,71 @@ +// (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 { CoreAppProvider } from '../../../providers/app'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from './delegate'; +import { CoreFileUploaderHelperProvider } from './helper'; +/** + * Handler to take a picture to upload it. + */ +@Injectable() +export class CoreFileUploaderCameraHandler implements CoreFileUploaderHandler { + name = 'CoreFileUploaderCamera'; + priority = 1800; + + constructor(private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, + private uploaderHelper: CoreFileUploaderHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise { + return this.appProvider.isMobile() || this.appProvider.canGetUserMedia(); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[] { + // Camera only supports JPEG and PNG. + return this.utils.filterByRegexp(mimetypes, /^image\/(jpeg|png)$/); + } + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.camera', + class: 'core-fileuploader-camera-handler', + icon: 'camera', + action: (maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]) => { + return this.uploaderHelper.uploadImage(false, maxSize, upload, mimetypes).then((result) => { + return { + treated: true, + result: result + }; + }); + } + }; + } +} diff --git a/src/core/fileuploader/providers/delegate.ts b/src/core/fileuploader/providers/delegate.ts new file mode 100644 index 000000000..7cedfcb50 --- /dev/null +++ b/src/core/fileuploader/providers/delegate.ts @@ -0,0 +1,303 @@ +// (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 { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; + +/** + * Interface that all handlers must implement. + */ +export interface CoreFileUploaderHandler { + /** + * A name to identify the addon. + * @type {string} + */ + name: string; + + /** + * Handler's priority. The highest priority, the highest position. + * @type {string} + */ + priority?: number; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise; + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[]; + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData; +}; + +/** + * Data needed to render the handler in the file picker. It must be returned by the handler. + */ +export interface CoreFileUploaderHandlerData { + /** + * The title to display in the handler. + * @type {string} + */ + title: string; + + /** + * The icon to display in the handler. + * @type {string} + */ + icon?: string; + + /** + * The class to assign to the handler item. + * @type {string} + */ + class?: string; + + /** + * Action to perform when the handler is clicked. + * + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {boolean} [upload] Whether the file should be uploaded. + * @param {boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved with the result of picking/uploading the file. + */ + action?(maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]) + : Promise; + + /** + * Function called after the handler is rendered. + * + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {boolean} [upload] Whether the file should be uploaded. + * @param {boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + */ + afterRender?(maxSize: number, upload: boolean, allowOffline: boolean, mimetypes: string[]) : void; +}; + +/** + * The result of clicking a handler. + */ +export interface CoreFileUploaderHandlerResult { + /** + * Whether the file was treated (uploaded or copied to tmp folder). + * @type {boolean} + */ + treated: boolean; + + /** + * The path of the file picked. Required if treated=false and fileEntry is not set. + * @type {string} + */ + path?: string; + + /** + * The fileEntry of the file picked. Required if treated=false and path is not set. + * @type {any} + */ + fileEntry?: any; + + /** + * Whether the file should be deleted after the upload. Ignored if treated=true. + * @type {boolean} + */ + delete?: boolean; + + /** + * The result of picking/uploading the file. Ignored if treated=false. + * @type {any} + */ + result?: any; +}; + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreFileUploaderHandlerDataToReturn extends CoreFileUploaderHandlerData { + /** + * Handler's priority. + * @type {number} + */ + priority?: number; + + + /** + * Supported mimetypes. + * @type {string[]} + */ + mimetypes?: string[]; +}; + +/** + * Delegate to register handlers to be shown in the file picker. + */ +@Injectable() +export class CoreFileUploaderDelegate { + protected logger; + protected handlers: {[s: string]: CoreFileUploaderHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreFileUploaderHandler} = {}; // Handlers enabled for the current site. + protected lastUpdateHandlersStart: number; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider) { + this.logger = logger.getInstance('CoreCourseModuleDelegate'); + + eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.REMOTE_ADDONS_LOADED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.LOGOUT, this.clearSiteHandlers.bind(this)); + } + + /** + * Clear current site handlers. Reserved for core use. + */ + protected clearSiteHandlers() : void { + this.enabledHandlers = {}; + } + + /** + * Get the handlers for the current site. + * + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {CoreFileUploaderHandlerDataToReturn[]} List of handlers data. + */ + getHandlers(mimetypes: string[]) : CoreFileUploaderHandlerDataToReturn[] { + let handlers = []; + + for (let name in this.enabledHandlers) { + let handler = this.enabledHandlers[name], + supportedMimetypes; + + if (mimetypes) { + if (!handler.getSupportedMimetypes) { + // Handler doesn't implement a required function, don't add it. + return; + } + + supportedMimetypes = handler.getSupportedMimetypes(mimetypes); + + if (!supportedMimetypes.length) { + // Handler doesn't support any mimetype, don't add it. + return; + } + } + + let data : CoreFileUploaderHandlerDataToReturn = handler.getData(); + data.priority = handler.priority; + data.mimetypes = supportedMimetypes; + handlers.push(data); + } + + return handlers; + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Register a handler. + * + * @param {CoreFileUploaderHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreFileUploaderHandler) : boolean { + if (typeof this.handlers[handler.name] !== 'undefined') { + this.logger.log(`Addon '${handler.name}' already registered`); + return false; + } + this.logger.log(`Registered addon '${handler.name}'`); + this.handlers[handler.name] = handler; + return true; + } + + /** + * Update the handler for the current site. + * + * @param {CoreFileUploaderHandler} handler The handler to check. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + protected updateHandler(handler: CoreFileUploaderHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(); + + if (!this.sitesProvider.isLoggedIn()) { + promise = Promise.reject(null); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() === siteId) { + if (enabled) { + this.enabledHandlers[handler.name] = handler; + } else { + delete this.enabledHandlers[handler.name]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + protected updateHandlers() : Promise { + let promises = [], + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).catch(() => { + // Never reject. + }); + } +} diff --git a/src/core/fileuploader/providers/file-handler.ts b/src/core/fileuploader/providers/file-handler.ts new file mode 100644 index 000000000..8240c991b --- /dev/null +++ b/src/core/fileuploader/providers/file-handler.ts @@ -0,0 +1,118 @@ +// (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 { Platform } from 'ionic-angular'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from './delegate'; +import { CoreFileUploaderHelperProvider } from './helper'; +import { CoreFileUploaderProvider } from './fileuploader'; +/** + * Handler to upload any type of file. + */ +@Injectable() +export class CoreFileUploaderFileHandler implements CoreFileUploaderHandler { + name = 'CoreFileUploaderFile'; + priority = 1200; + + constructor(private appProvider: CoreAppProvider, private platform: Platform, private timeUtils: CoreTimeUtilsProvider, + private uploaderHelper: CoreFileUploaderHelperProvider, private uploaderProvider: CoreFileUploaderProvider, + private domUtils: CoreDomUtilsProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise { + return this.platform.is('android') || !this.appProvider.isMobile() || + (this.platform.is('ios') && this.platform.version().major >= 9); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[] { + return mimetypes; + } + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData { + const isIOS = this.platform.is('ios'); + + return { + title: isIOS ? 'core.fileuploader.more' : 'core.fileuploader.file', + class: 'core-fileuploader-file-handler', + icon: isIOS ? 'more' : 'folder', + afterRender: (maxSize: number, upload: boolean, allowOffline: boolean, mimetypes: string[]) => { + // Add an invisible file input in the file handler. + // It needs to be done like this because the action sheet items don't accept inputs. + const element = document.querySelector('.core-fileuploader-file-handler'); + if (element) { + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + if (mimetypes && mimetypes.length && (!this.platform.is('android') || mimetypes.length == 1)) { + // Don't use accept attribute in Android with several mimetypes, it's not supported. + input.setAttribute('accept', mimetypes.join(', ')); + } + + input.addEventListener('change', (evt: Event) => { + let file = input.files[0], + fileName; + input.value = ''; // Unset input. + if (!file) { + return; + } + + // Verify that the mimetype of the file is supported, in case the accept attribute isn't supported. + const error = this.uploaderProvider.isInvalidMimetype(mimetypes, file.name, file.type); + if (error) { + this.domUtils.showErrorModal(error); + return; + } + + fileName = file.name; + if (isIOS) { + // Check the name of the file and add a timestamp if needed (take picture). + const matches = fileName.match(/image\.(jpe?g|png)/); + if (matches) { + fileName = 'image_' + this.timeUtils.readableTimestamp() + '.' + matches[1]; + } + } + + // Upload the picked file. + this.uploaderHelper.uploadFileObject(file, maxSize, upload, allowOffline, fileName).then((result) => { + this.uploaderHelper.fileUploaded(result); + }).catch((error) => { + if (error) { + this.domUtils.showErrorModal(error); + } + }); + }); + + element.appendChild(input); + } + } + }; + } +} diff --git a/src/core/fileuploader/providers/fileuploader.ts b/src/core/fileuploader/providers/fileuploader.ts new file mode 100644 index 000000000..06c3716ba --- /dev/null +++ b/src/core/fileuploader/providers/fileuploader.ts @@ -0,0 +1,498 @@ +// (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 { Platform } from 'ionic-angular'; +import { MediaFile } from '@ionic-native/media-capture'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreMimetypeUtilsProvider } from '../../../providers/utils/mimetype'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreWSFileUploadOptions } from '../../../providers/ws'; + +/** + * Interface for file upload options. + */ +export interface CoreFileUploaderOptions extends CoreWSFileUploadOptions { + deleteAfterUpload?: boolean; // Whether the file should be deleted after the upload (if success). +}; + +/** + * Service to upload files. + */ +@Injectable() +export class CoreFileUploaderProvider { + public static LIMITED_SIZE_WARNING = 1048576; // 1 MB. + public static WIFI_SIZE_WARNING = 10485760; // 10 MB. + + protected logger; + + constructor(logger: CoreLoggerProvider, private fileProvider: CoreFileProvider, private textUtils: CoreTextUtilsProvider, + private utils: CoreUtilsProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, + private mimeUtils: CoreMimetypeUtilsProvider, private filepoolProvider: CoreFilepoolProvider, + private platform: Platform, private translate: TranslateService) { + this.logger = logger.getInstance('CoreFileUploaderProvider'); + } + + /** + * Add a dot to the beginning of an extension. + * + * @param {string} extension Extension. + * @return {string} Treated extension. + */ + protected addDot(extension: string) : string { + return '.' + extension; + } + + /** + * Compares two file lists and returns if they are different. + * + * @param {any[]} a First file list. + * @param {any[]} b Second file list. + * @return {boolean} Whether both lists are different. + */ + areFileListDifferent(a: any[], b: any[]) : boolean { + a = a || []; + b = b || []; + if (a.length != b.length) { + return true; + } + + // Currently we are going to compare the order of the files as well. + // This function can be improved comparing more fields or not comparing the order. + for (let i = 0; i < a.length; i++) { + if ((a[i].name || a[i].filename) != (b[i].name || b[i].filename)) { + return true; + } + } + + return false; + } + + /** + * Clear temporary attachments to be uploaded. + * Attachments already saved in an offline store will NOT be deleted. + * + * @param {any[]} files List of files. + */ + clearTmpFiles(files: any[]) : void { + // Delete the local files. + files.forEach((file) => { + if (!file.offline && file.remove) { + // Pass an empty function to prevent missing parameter error. + file.remove(() => {}); + } + }); + } + + /** + * Get the upload options for a file taken with the Camera Cordova plugin. + * + * @param {string} uri File URI. + * @param {boolean} [isFromAlbum] True if the image was taken from album, false if it's a new image taken with camera. + * @return {CoreFileUploaderOptions} Options. + */ + getCameraUploadOptions(uri: string, isFromAlbum?: boolean) : CoreFileUploaderOptions { + let extension = this.mimeUtils.getExtension(uri), + mimetype = this.mimeUtils.getMimeType(extension), + isIOS = this.platform.is('ios'), + options: CoreFileUploaderOptions = { + deleteAfterUpload: !isFromAlbum, + mimeType: mimetype + }; + + if (isIOS && (mimetype == 'image/jpeg' || mimetype == 'image/png')) { + // In iOS, the pictures can have repeated names, even if they come from the album. + options.fileName = 'image_' + this.timeUtils.readableTimestamp() + '.' + extension; + } else { + // Use the same name that the file already has. + options.fileName = this.fileProvider.getFileAndDirectoryFromPath(uri).name; + } + + if (isFromAlbum) { + // If the file was picked from the album, delete it only if it was copied to the app's folder. + options.deleteAfterUpload = this.fileProvider.isFileInAppFolder(uri); + + if (this.platform.is('android')) { + // Picking an image from album in Android adds a timestamp at the end of the file. Delete it. + options.fileName = options.fileName.replace(/(\.[^\.]*)\?[^\.]*$/, '$1'); + } + } + + return options; + } + + /** + * Get the upload options for a file of any type. + * + * @param {string} uri File URI. + * @param {string} name File name. + * @param {string} type File type. + * @param {boolean} [deleteAfterUpload] Whether the file should be deleted after upload. + * @param {string} [fileArea] File area to upload the file to. It defaults to 'draft'. + * @param {number} [itemId] Draft ID to upload the file to, 0 to create new. + * @return {CoreFileUploaderOptions} Options. + */ + getFileUploadOptions(uri: string, name: string, type: string, deleteAfterUpload?: boolean, fileArea?: string, itemId?: number) + : CoreFileUploaderOptions { + let options : CoreFileUploaderOptions = {}; + options.fileName = name; + options.mimeType = type || this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(options.fileName)); + options.deleteAfterUpload = !!deleteAfterUpload; + options.itemId = itemId || 0; + options.fileArea = fileArea; + + return options; + } + + /** + * Get the upload options for a file taken with the media capture Cordova plugin. + * + * @param {MediaFile} mediaFile File object to upload. + * @return {CoreFileUploaderOptions} Options. + */ + getMediaUploadOptions(mediaFile: MediaFile) : CoreFileUploaderOptions { + let options : CoreFileUploaderOptions = {}, + filename = mediaFile.name, + split; + + // Add a timestamp to the filename to make it unique. + split = filename.split('.'); + split[0] += '_' + this.timeUtils.readableTimestamp(); + filename = split.join('.'); + + options.fileName = filename; + options.deleteAfterUpload = true; + if (mediaFile.type) { + options.mimeType = mediaFile.type; + } else { + options.mimeType = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(options.fileName)); + } + return options; + } + + /** + * Get the files stored in a folder, marking them as offline. + * + * @param {string} folderPath Folder where to get the files. + * @return {Promise} Promise resolved with the list of files. + */ + getStoredFiles(folderPath: string) : Promise { + return this.fileProvider.getDirectoryContents(folderPath).then((files) => { + return this.markOfflineFiles(files); + }); + } + + /** + * Get stored files from combined online and offline file object. + * + * @param {{online: any[], offline: number}} filesObject The combined offline and online files object. + * @param {string} folderPath Folder path to get files from. + * @return {Promise} Promise resolved with files. + */ + getStoredFilesFromOfflineFilesObject(filesObject: {online: any[], offline: number}, folderPath: string) : Promise { + let files = []; + + if (filesObject) { + if (filesObject.online && filesObject.online.length > 0) { + files = this.utils.clone(filesObject.online); + } + + if (filesObject.offline > 0) { + return this.getStoredFiles(folderPath).then((offlineFiles) => { + return files.concat(offlineFiles); + }).catch(() => { + // Ignore not found files. + return files; + }); + } + } + return Promise.resolve(files); + } + + /** + * Check if a file's mimetype is invalid based on the list of accepted mimetypes. This function needs either the file's + * mimetype or the file's path/name. + * + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @param {string} [path] File's path or name. + * @param {string} [mimetype] File's mimetype. + * @return {string} Undefined if file is valid, error message if file is invalid. + */ + isInvalidMimetype(mimetypes?: string[], path?: string, mimetype?: string) : string { + let extension; + + if (mimetypes) { + // Verify that the mimetype of the file is supported. + if (mimetype) { + extension = this.mimeUtils.getExtension(mimetype); + } else { + extension = this.mimeUtils.getFileExtension(path); + mimetype = this.mimeUtils.getMimeType(extension); + } + + if (mimetype && mimetypes.indexOf(mimetype) == -1) { + extension = extension || this.translate.instant('core.unknown'); + return this.translate.instant('core.fileuploader.invalidfiletype', {$a: extension}); + } + } + } + + /** + * Mark files as offline. + * + * @param {any[]} files Files to mark as offline. + * @return {any[]} Files marked as offline. + */ + markOfflineFiles(files: any[]) : any[] { + // Mark the files as pending offline. + files.forEach((file) => { + file.offline = true; + file.filename = file.name; + }); + return files; + } + + /** + * Parse filetypeList to get the list of allowed mimetypes and the data to render information. + * + * @param {string} filetypeList Formatted string list where the mimetypes can be checked. + * @return {{info: any[], mimetypes: string[]}} Mimetypes and the filetypes informations. + */ + prepareFiletypeList(filetypeList: string) : {info: any[], mimetypes: string[]} { + let filetypes = filetypeList.split(/[;, ]+/g), + mimetypes = {}, // Use an object to prevent duplicates. + typesInfo = []; + + filetypes.forEach((filetype) => { + filetype = filetype.trim(); + + if (filetype) { + if (filetype.indexOf('/') != -1) { + // It's a mimetype. + typesInfo.push({ + name: this.mimeUtils.getMimetypeDescription(filetype), + extlist: this.mimeUtils.getExtensions(filetype).map(this.addDot).join(' ') + }); + + mimetypes[filetype] = true; + } else if (filetype.indexOf('.') === 0) { + // It's an extension. + let mimetype = this.mimeUtils.getMimeType(filetype); + typesInfo.push({ + name: mimetype ? this.mimeUtils.getMimetypeDescription(mimetype) : false, + extlist: filetype + }); + + if (mimetype) { + mimetypes[mimetype] = true; + } + } else { + // It's a group. + let groupExtensions = this.mimeUtils.getGroupMimeInfo(filetype, 'extensions'), + groupMimetypes = this.mimeUtils.getGroupMimeInfo(filetype, 'mimetypes'); + + if (groupExtensions.length > 0) { + typesInfo.push({ + name: this.mimeUtils.getTranslatedGroupName(filetype), + extlist: groupExtensions ? groupExtensions.map(this.addDot).join(' ') : '' + }); + + groupMimetypes.forEach((mimetype) => { + if (mimetype) { + mimetypes[mimetype] = true; + } + }); + } else { + // Treat them as extensions. + filetype = this.addDot(filetype); + let mimetype = this.mimeUtils.getMimeType(filetype); + typesInfo.push({ + name: mimetype ? this.mimeUtils.getMimetypeDescription(mimetype) : false, + extlist: filetype + }); + + if (mimetype) { + mimetypes[mimetype] = true; + } + } + } + } + }); + + return { + info: typesInfo, + mimetypes: Object.keys(mimetypes) + }; + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be uploaded later. + * + * @param {string} folderPath Path of the folder where to store the files. + * @param {any[]} files List of files. + * @return {Promise<{online: any[], offline: number}>} Promise resolved if success. + */ + storeFilesToUpload(folderPath: string, files: any[]) : Promise<{online: any[], offline: number}> { + let result = { + online: [], + offline: 0 + }; + + if (!files || !files.length) { + return Promise.resolve(result); + } + + // Remove unused files from previous saves. + return this.fileProvider.removeUnusedFiles(folderPath, files).then(() => { + let promises = []; + + files.forEach((file) => { + if (file.filename && !file.name) { + // It's an online file, add it to the result and ignore it. + result.online.push({ + filename: file.filename, + fileurl: file.fileurl + }); + } else if (!file.name) { + // Error. + promises.push(Promise.reject(null)); + } else if (file.fullPath && file.fullPath.indexOf(folderPath) != -1) { + // File already in the submission folder. + result.offline++; + } else { + // Local file, copy it. Use copy instead of move to prevent having a unstable state if + // some copies succeed and others don't. + let destFile = this.textUtils.concatenatePaths(folderPath, file.name); + promises.push(this.fileProvider.copyFile(file.toURL(), destFile)); + result.offline++; + } + }); + + return Promise.all(promises).then(() => { + return result; + }); + }); + } + + /** + * Upload a file. + * + * @param {string} uri File URI. + * @param {CoreFileUploaderOptions} [options] Options for the upload. + * @param {Function} [onProgress] Function to call on progress. + * @param {string} [siteId] Id of the site to upload the file to. If not defined, use current site. + * @return {Promise} Promise resolved when done. + */ + uploadFile(uri: string, options?: CoreFileUploaderOptions, onProgress?: (event: ProgressEvent) => any, + siteId?: string) : Promise { + options = options || {}; + + const deleteAfterUpload = options.deleteAfterUpload, + ftOptions = this.utils.clone(options); + + delete ftOptions.deleteAfterUpload; + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.uploadFile(uri, ftOptions, onProgress); + }).then((result) => { + if (deleteAfterUpload) { + setTimeout(() => { + // Use set timeout, otherwise in Electron the upload threw an error sometimes. + this.fileProvider.removeExternalFile(uri); + }, 500); + } + return result; + }); + } + + /** + * Upload a file to a draft area. If the file is an online file it will be downloaded and then re-uploaded. + * + * @param {any} file Online file or local FileEntry. + * @param {number} [itemId] Draft ID to use. Undefined or 0 to create a new draft ID. + * @param {string} [component] The component to set to the downloaded files. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the itemId. + */ + uploadOrReuploadFile(file: any, itemId?: number, component?: string, componentId?: string|number, + siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let promise, + fileName; + + if (file.filename && !file.name) { + // It's an online file. We need to download it and re-upload it. + fileName = file.filename; + promise = this.filepoolProvider.downloadUrl(siteId, file.url || file.fileurl, false, component, componentId, + file.timemodified, undefined, undefined, file).then((path) => { + return this.fileProvider.getExternalFile(path); + }); + } else { + // Local file, we already have the file entry. + fileName = file.name; + promise = Promise.resolve(file); + } + + return promise.then((fileEntry) => { + // Now upload the file. + let options = this.getFileUploadOptions(fileEntry.toURL(), fileName, fileEntry.type, true, 'draft', itemId); + return this.uploadFile(fileEntry.toURL(), options, undefined, siteId).then((result) => { + return result.itemid; + }); + }); + } + + /** + * Given a list of files (either online files or local files), upload them to a draft area and return the draft ID. + * Online files will be downloaded and then re-uploaded. + * If there are no files to upload it will return a fake draft ID (1). + * + * @param {any[]} files List of files. + * @param {string} [component] The component to set to the downloaded files. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the itemId. + */ + uploadOrReuploadFiles(files: any[], component?: string, componentId?: string|number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!files || !files.length) { + // Return fake draft ID. + return Promise.resolve(1); + } + + // Upload only the first file first to get a draft id. + return this.uploadOrReuploadFile(files[0], 0, component, componentId, siteId).then((itemId) => { + let promises = []; + + for (let i = 1; i < files.length; i++) { + let file = files[i]; + promises.push(this.uploadOrReuploadFile(file, itemId, component, componentId, siteId)); + } + + return Promise.all(promises).then(() => { + return itemId; + }); + }); + } +} diff --git a/src/core/fileuploader/providers/helper.ts b/src/core/fileuploader/providers/helper.ts new file mode 100644 index 000000000..07936bb1d --- /dev/null +++ b/src/core/fileuploader/providers/helper.ts @@ -0,0 +1,687 @@ +// (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 { ActionSheetController, ActionSheet, Platform } from 'ionic-angular'; +import { MediaCapture, MediaFile } from '@ionic-native/media-capture'; +import { Camera, CameraOptions } from '@ionic-native/camera'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; +import { CoreUtilsProvider, PromiseDefer } from '../../../providers/utils/utils'; +import { CoreFileUploaderProvider, CoreFileUploaderOptions } from './fileuploader'; +import { CoreFileUploaderDelegate } from './delegate'; + +/** + * Helper service to upload files. + */ +@Injectable() +export class CoreFileUploaderHelperProvider { + + protected logger; + protected filePickerDeferred: PromiseDefer; + protected actionSheet: ActionSheet; + + constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private translate: TranslateService, + private fileUploaderProvider: CoreFileUploaderProvider, private domUtils: CoreDomUtilsProvider, + private textUtils: CoreTextUtilsProvider, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, + private actionSheetCtrl: ActionSheetController, private uploaderDelegate: CoreFileUploaderDelegate, + private mediaCapture: MediaCapture, private camera: Camera, private platform: Platform) { + this.logger = logger.getInstance('CoreFileUploaderProvider'); + } + + /** + * Show a confirmation modal to the user if the size of the file is bigger than the allowed threshold. + * + * @param {number} size File size. + * @param {boolean} [alwaysConfirm] True to show a confirm even if the size isn't high. + * @param {boolean} [allowOffline] True to allow uploading in offline. + * @param {number} [wifiThreshold] Threshold for WiFi connection. Default: CoreFileUploaderProvider.WIFI_SIZE_WARNING. + * @param {number} [limitedThreshold] Threshold for limited connection. Default: CoreFileUploaderProvider.LIMITED_SIZE_WARNING. + * @return {Promise} Promise resolved when the user confirms or if there's no need to show a modal. + */ + confirmUploadFile(size: number, alwaysConfirm?: boolean, allowOffline?: boolean, wifiThreshold?: number, + limitedThreshold?: number) : Promise { + if (size == 0) { + return Promise.resolve(); + } + + if (!allowOffline && !this.appProvider.isOnline()) { + return Promise.reject(this.translate.instant('core.fileuploader.errormustbeonlinetoupload')); + } + + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreFileUploaderProvider.WIFI_SIZE_WARNING : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreFileUploaderProvider.LIMITED_SIZE_WARNING : limitedThreshold; + + if (size < 0) { + return this.domUtils.showConfirm(this.translate.instant('core.fileuploader.confirmuploadunknownsize')); + } else if (size >= wifiThreshold || (this.appProvider.isNetworkAccessLimited() && size >= limitedThreshold)) { + let readableSize = this.textUtils.bytesToSize(size, 2); + return this.domUtils.showConfirm(this.translate.instant('core.fileuploader.confirmuploadfile', {size: readableSize})); + } else if (alwaysConfirm) { + return this.domUtils.showConfirm(this.translate.instant('core.areyousure')); + } else { + return Promise.resolve(); + } + } + + /** + * Create a temporary copy of a file and upload it. + * + * @param {any} file File to copy and upload. + * @param {boolean} [upload] True if the file should be uploaded, false to return the copy of the file. + * @param {string} [name] Name to use when uploading the file. If not defined, use the file's name. + * @return {Promise} Promise resolved when the file is uploaded. + */ + copyAndUploadFile(file: any, upload?: boolean, name?: string) : Promise { + name = name || file.name; + + let modal = this.domUtils.showModalLoading('core.fileuploader.readingfile', true), + fileData; + + // We have the data of the file to be uploaded, but not its URL (needed). Create a copy of the file to upload it. + return this.fileProvider.readFileData(file, this.fileProvider.FORMATARRAYBUFFER).then((data) => { + fileData = data; + + // Get unique name for the copy. + return this.fileProvider.getUniqueNameInFolder(this.fileProvider.TMPFOLDER, name); + }).then((newName) => { + let filePath = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, newName); + + return this.fileProvider.writeFile(filePath, fileData); + }).catch((error) => { + this.logger.error('Error reading file to upload.', error); + modal.dismiss(); + return Promise.reject(this.translate.instant('core.fileuploader.errorreadingfile')); + }).then((fileEntry) => { + modal.dismiss(); + + if (upload) { + // Pass true to delete the copy after the upload. + return this.uploadGenericFile(fileEntry.toURL(), name, file.type, true); + } else { + return fileEntry; + } + }); + } + + /** + * Copy or move a file to the app temporary folder. + * + * @param {string} path Path of the file. + * @param {boolean} shouldDelete True if original file should be deleted (move), false otherwise (copy). + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {string} [defaultExt] Defaut extension to use if the file doesn't have any. + * @return {Promise} Promise resolved with the copied file. + */ + protected copyToTmpFolder(path: string, shouldDelete: boolean, maxSize?: number, defaultExt?: string) : Promise { + let fileName = this.fileProvider.getFileAndDirectoryFromPath(path).name, + promise, + fileTooLarge; + + // Check that size isn't too large. + if (typeof maxSize != 'undefined' && maxSize != -1) { + promise = this.fileProvider.getExternalFile(path).then((fileEntry) => { + return this.fileProvider.getFileObjectFromFileEntry(fileEntry).then((file) => { + if (file.size > maxSize) { + fileTooLarge = file; + } + }); + }).catch(() => { + // Ignore failures. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + if (fileTooLarge) { + return this.errorMaxBytes(maxSize, fileTooLarge.name); + } + + // File isn't too large. + // Picking an image from album in Android adds a timestamp at the end of the file. Delete it. + fileName = fileName.replace(/(\.[^\.]*)\?[^\.]*$/, '$1'); + + // Get a unique name in the folder to prevent overriding another file. + return this.fileProvider.getUniqueNameInFolder(this.fileProvider.TMPFOLDER, fileName, defaultExt); + }).then((newName) => { + // Now move or copy the file. + const destPath = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, newName); + if (shouldDelete) { + return this.fileProvider.moveExternalFile(path, destPath); + } else { + return this.fileProvider.copyExternalFile(path, destPath); + } + }); + } + + /** + * Function called when trying to upload a file bigger than max size. Shows an error. + * + * @param {number} maxSize Max size (bytes). + * @param {string} fileName Name of the file. + * @return {Promise} Rejected promise. + */ + protected errorMaxBytes(maxSize: number, fileName: string) : Promise { + let errorMessage = this.translate.instant('core.fileuploader.maxbytesfile', {$a: { + file: fileName, + size: this.textUtils.bytesToSize(maxSize, 2) + }}); + + this.domUtils.showErrorModal(errorMessage); + return Promise.reject(null); + } + + /** + * Function called when the file picker is closed. + */ + filePickerClosed() : void { + if (this.filePickerDeferred) { + this.filePickerDeferred.reject(); + this.filePickerDeferred = undefined; + } + // Close the action sheet if it's opened. + if (this.actionSheet) { + this.actionSheet.dismiss(); + } + } + + /** + * Function to call once a file is uploaded using the file picker. + * + * @param {any} result Result of the upload process. + */ + fileUploaded(result: any) : void { + if (this.filePickerDeferred) { + this.filePickerDeferred.resolve(result); + this.filePickerDeferred = undefined; + } + // Close the action sheet if it's opened. + if (this.actionSheet) { + this.actionSheet.dismiss(); + } + } + + /** + * Open the "file picker" to select and upload a file. + * + * @param {number} [maxSize] Max size of the file to upload. If not defined or -1, no max size. + * @param {string} [title] File picker title. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved when a file is uploaded, rejected if file picker is closed without a file uploaded. + * The resolve value is the response of the upload request. + */ + selectAndUploadFile(maxSize?: number, title?: string, mimetypes?: string[]) : Promise { + return this.selectFileWithPicker(maxSize, false, title, mimetypes, true); + } + + /** + * Open the "file picker" to select a file without uploading it. + * + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {string} [title] File picker title. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved when a file is selected, rejected if file picker is closed without selecting a file. + * The resolve value is the FileEntry of a copy of the picked file, so it can be deleted afterwards. + */ + selectFile(maxSize?: number, allowOffline?: boolean, title?: string, mimetypes?: string[]) + : Promise { + return this.selectFileWithPicker(maxSize, allowOffline, title, mimetypes, false); + } + + /** + * Open the "file picker" to select a file and maybe uploading it. + * + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {string} [title] File picker title. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @param {boolean} [upload] Whether the file should be uploaded. + * @return {Promise} Promise resolved when a file is selected/uploaded, rejected if file picker is closed. + */ + protected selectFileWithPicker(maxSize?: number, allowOffline?: boolean, title?: string, mimetypes?: string[], + upload?: boolean) : Promise { + // Create the cancel button and get the handlers to upload the file. + let buttons: any[] = [{ + text: this.translate.instant('core.cancel'), + role: 'cancel', + handler: () => { + // User cancelled the action sheet. + this.filePickerClosed(); + } + }], + handlers = this.uploaderDelegate.getHandlers(mimetypes); + + this.filePickerDeferred = this.utils.promiseDefer(); + + // Sort the handlers by priority. + handlers.sort((a, b) => { + return a.priority <= b.priority ? 1 : -1; + }); + + // Create a button for each handler. + handlers.forEach((handler) => { + buttons.push({ + text: this.translate.instant(handler.title), + icon: handler.icon, + cssClass: handler.class, + handler: () => { + if (!handler.action) { + // Nothing to do. + return false; + } + + if (!allowOffline && !this.appProvider.isOnline()) { + // Not allowed, show error. + this.domUtils.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true); + return false; + } + + handler.action(maxSize, upload, allowOffline, handler.mimetypes).then((data) => { + if (data.treated) { + // The handler already treated the file. Return the result. + return data.result; + } else { + // The handler didn't treat the file, we need to do it. + if (data.fileEntry) { + // The handler provided us a fileEntry, use it. + return this.uploadFileEntry(data.fileEntry, data.delete, maxSize, upload, allowOffline); + } else if (data.path) { + // The handler provided a path. First treat it like it's a relative path. + return this.fileProvider.getFile(data.path).catch(() => { + // File not found, it's probably an absolute path. + return this.fileProvider.getExternalFile(data.path); + }).then((fileEntry) => { + // File found, treat it. + return this.uploadFileEntry(fileEntry, data.delete, maxSize, upload, allowOffline); + }); + } + + // Nothing received, fail. + return Promise.reject('No file received'); + } + }).then((result) => { + // Success uploading or picking, return the result. + this.fileUploaded(result); + }).catch((error) => { + if (error) { + this.domUtils.showErrorModal(error); + } + }); + + // Do not close the action sheet, it will be closed if success. + return false; + } + }); + }); + + this.actionSheet = this.actionSheetCtrl.create({ + title: title ? title : this.translate.instant('core.fileuploader.' + (upload ? 'uploadafile' : 'selectafile')), + buttons: buttons + }); + this.actionSheet.present(); + + // Call afterRender for each button. + setTimeout(() => { + handlers.forEach((handler) => { + if (handler.afterRender) { + handler.afterRender(maxSize, upload, allowOffline, handler.mimetypes); + } + }); + }, 500); + + return this.filePickerDeferred.promise; + } + + /** + * Convenience function to upload a file on a certain site, showing a confirm if needed. + * + * @param {any} fileEntry FileEntry of the file to upload. + * @param {boolean} [deleteAfterUpload] Whether the file should be deleted after upload. + * @param {string} [siteId] Id of the site to upload the file to. If not defined, use current site. + * @return {Promise} Promise resolved when the file is uploaded. + */ + showConfirmAndUploadInSite(fileEntry: any, deleteAfterUpload?: boolean, siteId?: string) : Promise { + return this.fileProvider.getFileObjectFromFileEntry(fileEntry).then((file) => { + return this.confirmUploadFile(file.size).then(() => { + return this.uploadGenericFile(fileEntry.toURL(), file.name, file.type, deleteAfterUpload, siteId).then(() => { + this.domUtils.showAlertTranslated('core.success', 'core.fileuploader.fileuploaded'); + }); + }).catch((err) => { + if (err) { + this.domUtils.showErrorModal(err); + } + return Promise.reject(null); + }); + }, () => { + this.domUtils.showErrorModal('core.fileuploader.errorreadingfile', true); + return Promise.reject(null); + }); + } + + /** + * Treat a capture audio/video error. + * + * @param {any} error Error returned by the Cordova plugin. Can be a string or an object. + * @param {string} defaultMessage Key of the default message to show. + * @return {Promise} Rejected promise. If it doesn't have an error message it means it was cancelled. + */ + protected treatCaptureError(error: any, defaultMessage: string) : Promise { + // Cancelled or error. If cancelled, error is an object with code = 3. + if (error) { + if (typeof error === 'string') { + this.logger.error('Error while recording audio/video: ' + error); + if (error.indexOf('No Activity found') > -1) { + // User doesn't have an app to do this. + return Promise.reject(this.translate.instant('core.fileuploader.errornoapp')); + } else { + return Promise.reject(this.translate.instant(defaultMessage)); + } + } else { + if (error.code != 3) { + // Error, not cancelled. + this.logger.error('Error while recording audio/video', error); + return Promise.reject(this.translate.instant(defaultMessage)); + } else { + this.logger.debug('Cancelled'); + } + } + } + return Promise.reject(null); + } + + /** + * Treat a capture image or browse album error. + * + * @param {string} error Error returned by the Cordova plugin. + * @param {string} defaultMessage Key of the default message to show. + * @return {Promise} Rejected promise. If it doesn't have an error message it means it was cancelled. + */ + protected treatImageError(error: string, defaultMessage: string) : Promise { + // Cancelled or error. + if (error) { + if (typeof error == 'string') { + if (error.toLowerCase().indexOf('error') > -1 || error.toLowerCase().indexOf('unable') > -1) { + this.logger.error('Error getting image: ' + error); + return Promise.reject(error); + } else { + // User cancelled. + this.logger.debug('Cancelled'); + } + } else { + return Promise.reject(this.translate.instant(defaultMessage)); + } + } + return Promise.reject(null); + } + + /** + * Convenient helper for the user to record and upload a video. + * + * @param {boolean} isAudio True if uploading an audio, false if it's a video. + * @param {number} maxSize Max size of the upload. -1 for no max size. + * @param {boolean} [upload] True if the file should be uploaded, false to return the picked file. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved when done. + */ + uploadAudioOrVideo(isAudio: boolean, maxSize: number, upload?: boolean, mimetypes?: string[]) : Promise { + this.logger.debug('Trying to record a video file'); + + const options = {limit: 1, mimetypes: mimetypes}, + promise = isAudio ? this.mediaCapture.captureAudio(options) : this.mediaCapture.captureVideo(options); + + // The mimetypes param is only for desktop apps, the Cordova plugin doesn't support it. + return promise.then((medias) => { + // We used limit 1, we only want 1 media. + let media: MediaFile = medias[0], + path = media.fullPath, + error = this.fileUploaderProvider.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. + + if (error) { + return Promise.reject(error); + } + + if (upload) { + return this.uploadFile(path, maxSize, true, this.fileUploaderProvider.getMediaUploadOptions(media)); + } else { + // Copy or move the file to our temporary folder. + return this.copyToTmpFolder(path, true, maxSize); + } + }, (error) => { + const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; + return this.treatCaptureError(error, defaultError); + }); + } + + /** + * Uploads a file of any type. + * This function will not check the size of the file, please check it before calling this function. + * + * @param {string} uri File URI. + * @param {string} name File name. + * @param {string} type File type. + * @param {boolean} [deleteAfterUpload] Whether the file should be deleted after upload. + * @param {string} [siteId] Id of the site to upload the file to. If not defined, use current site. + * @return {Promise} Promise resolved when the file is uploaded. + */ + uploadGenericFile(uri: string, name: string, type: string, deleteAfterUpload?: boolean, siteId?: string) : Promise { + let options = this.fileUploaderProvider.getFileUploadOptions(uri, name, type, deleteAfterUpload); + return this.uploadFile(uri, -1, false, options, siteId); + } + + /** + * Convenient helper for the user to upload an image, either from the album or taking it with the camera. + * + * @param {Boolean} fromAlbum True if the image should be selected from album, false if it should be taken with camera. + * @param {Number} maxSize Max size of the upload. -1 for no max size. + * @param {Boolean} upload True if the image should be uploaded, false to return the picked file. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} The reject contains the error message, if there is no error message + * then we can consider that this is a silent fail. + */ + uploadImage(fromAlbum, maxSize, upload, mimetypes) { + this.logger.debug('Trying to capture an image with camera'); + + let options: CameraOptions = { + quality: 50, + destinationType: this.camera.DestinationType.FILE_URI, + correctOrientation: true + }; + + if (fromAlbum) { + const imageSupported = !mimetypes || this.utils.indexOfRegexp(mimetypes, /^image\//) > -1, + videoSupported = !mimetypes || this.utils.indexOfRegexp(mimetypes, /^video\//) > -1; + + options.sourceType = this.camera.PictureSourceType.PHOTOLIBRARY; + options.popoverOptions = { + x: 10, + y: 10, + width: this.platform.width() - 200, + height: this.platform.height() - 200, + arrowDir: this.camera.PopoverArrowDirection.ARROW_ANY + }; + + // Determine the mediaType based on the mimetypes. + if (imageSupported && !videoSupported) { + options.mediaType = this.camera.MediaType.PICTURE; + } else if (!imageSupported && videoSupported) { + options.mediaType = this.camera.MediaType.VIDEO; + } else if (this.platform.is('ios')) { + // Only get all media in iOS because in Android using this option allows uploading any kind of file. + options.mediaType = this.camera.MediaType.ALLMEDIA; + } + } else if (mimetypes) { + if (mimetypes.indexOf('image/jpeg') > -1) { + options.encodingType = this.camera.EncodingType.JPEG; + } else if (mimetypes.indexOf('image/png') > -1) { + options.encodingType = this.camera.EncodingType.PNG; + } + } + + return this.camera.getPicture(options).then((path) => { + let error = this.fileUploaderProvider.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. + if (error) { + return Promise.reject(error); + } + + if (upload) { + return this.uploadFile(path, maxSize, true, this.fileUploaderProvider.getCameraUploadOptions(path, fromAlbum)); + } else { + // Copy or move the file to our temporary folder. + return this.copyToTmpFolder(path, !fromAlbum, maxSize, 'jpg'); + } + }, (error) => { + let defaultError = fromAlbum ? 'core.fileuploader.errorgettingimagealbum' : 'core.fileuploader.errorcapturingimage'; + return this.treatImageError(error, defaultError); + }); + } + + /** + * Upload a file given the file entry. + * + * @param {any} fileEntry The file entry. + * @param {boolean} deleteAfter True if the file should be deleted once treated. + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {boolean} [upload] True if the file should be uploaded, false to return the picked file. + * @param {boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {string} [name] Name to use when uploading the file. If not defined, use the file's name. + * @return {Promise} Promise resolved when done. + */ + uploadFileEntry(fileEntry: any, deleteAfter: boolean, maxSize?: number, upload?: boolean, allowOffline?: boolean, + name?: string) : Promise { + return this.fileProvider.getFileObjectFromFileEntry(fileEntry).then((file) => { + return this.uploadFileObject(file, maxSize, upload, allowOffline, name).then((result) => { + if (deleteAfter) { + // We have uploaded and deleted a copy of the file. Now delete the original one. + this.fileProvider.removeFileByFileEntry(fileEntry); + } + return result; + }); + }); + } + + /** + * Upload a file given the file object. + * + * @param {any} file The file object. + * @param {number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {boolean} [upload] True if the file should be uploaded, false to return the picked file. + * @param {boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {string} [name] Name to use when uploading the file. If not defined, use the file's name. + * @return {Promise} Promise resolved when done. + */ + uploadFileObject(file: any, maxSize?: number, upload?: boolean, allowOffline?: boolean, name?: string) : Promise { + if (maxSize != -1 && file.size > maxSize) { + return this.errorMaxBytes(maxSize, file.name); + } + + return this.confirmUploadFile(file.size, false, allowOffline).then(() => { + // We have the data of the file to be uploaded, but not its URL (needed). Create a copy of the file to upload it. + return this.copyAndUploadFile(file, upload, name); + }); + } + + /** + * Convenience function to upload a file, allowing to retry if it fails. + * + * @param {string} path Absolute path of the file to upload. + * @param {number} maxSize Max size of the upload. -1 for no max size. + * @param {boolean} checkSize True to check size. + * @param {CoreFileUploaderOptions} Options. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if the file is uploaded, rejected otherwise. + */ + protected uploadFile(path: string, maxSize: number, checkSize: boolean, options: CoreFileUploaderOptions, siteId?: string) + : Promise { + + let errorStr = this.translate.instant('core.error'), + retryStr = this.translate.instant('core.retry'), + uploadingStr = this.translate.instant('core.fileuploader.uploading'), + promise, + file, + errorUploading = (error) => { + // Allow the user to retry. + return this.domUtils.showConfirm(error, errorStr, retryStr).then(() => { + // Try again. + return this.uploadFile(path, maxSize, checkSize, options, siteId); + }, () => { + // User cancelled. Delete the file if needed. + if (options.deleteAfterUpload) { + this.fileProvider.removeExternalFile(path); + } + return Promise.reject(null); + }); + }; + + if (!this.appProvider.isOnline()) { + return errorUploading(this.translate.instant('core.fileuploader.errormustbeonlinetoupload')); + } + + if (checkSize) { + // Check that file size is the right one. + promise = this.fileProvider.getExternalFile(path).then((fileEntry) => { + return this.fileProvider.getFileObjectFromFileEntry(fileEntry).then((f) => { + file = f; + return file.size; + }); + }).catch(() => { + // Ignore failures. + }); + } else { + promise = Promise.resolve(0); + } + + return promise.then((size) => { + if (maxSize != -1 && size > maxSize) { + return this.errorMaxBytes(maxSize, file.name); + } + + if (size > 0) { + return this.confirmUploadFile(size); + } + }).then(() => { + // File isn't too large and user confirmed, let's upload. + let modal = this.domUtils.showModalLoading(uploadingStr); + + return this.fileUploaderProvider.uploadFile(path, options, (progress: ProgressEvent) => { + // Progress uploading. + if (progress && progress.lengthComputable) { + let perc = Math.min((progress.loaded / progress.total) * 100, 100); + if (perc >= 0) { + modal.setContent(this.translate.instant('core.fileuploader.uploadingperc', {$a: perc.toFixed(1)})); + if (modal._cmp && modal._cmp.changeDetectorRef) { + // Force a change detection, otherwise the content is not updated. + modal._cmp.changeDetectorRef.detectChanges(); + } + } + } + }, siteId).catch((error) => { + this.logger.error('Error uploading file.', error); + + modal.dismiss(); + if (typeof error != 'string') { + error = this.translate.instant('core.fileuploader.errorwhileuploading'); + } + return errorUploading(error); + }).finally(() => { + modal.dismiss(); + }); + }); + } +} diff --git a/src/core/fileuploader/providers/video-handler.ts b/src/core/fileuploader/providers/video-handler.ts new file mode 100644 index 000000000..d90cca555 --- /dev/null +++ b/src/core/fileuploader/providers/video-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 { Platform } from 'ionic-angular'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from './delegate'; +import { CoreFileUploaderHelperProvider } from './helper'; +/** + * Handler to record a video to upload it. + */ +@Injectable() +export class CoreFileUploaderVideoHandler implements CoreFileUploaderHandler { + name = 'CoreFileUploaderVideo'; + priority = 1400; + + constructor(private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, private platform: Platform, + private uploaderHelper: CoreFileUploaderHelperProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise { + return this.appProvider.isMobile() || (this.appProvider.canGetUserMedia() && this.appProvider.canRecordMedia()); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[] { + if (this.platform.is('ios')) { + // iOS records as MOV. + return this.utils.filterByRegexp(mimetypes, /^video\/quicktime$/); + } else if (this.platform.is('android')) { + // In Android we don't know the format the video will be recorded, so accept any video mimetype. + return this.utils.filterByRegexp(mimetypes, /^video\//); + } else { + // In desktop, support video formats that are supported by MediaRecorder. + let mediaRecorder = (window).MediaRecorder; + if (mediaRecorder) { + return mimetypes.filter(function(type) { + let matches = type.match(/^video\//); + return matches && matches.length && mediaRecorder.isTypeSupported(type); + }); + } + } + + return []; + } + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData { + return { + title: 'core.fileuploader.video', + class: 'core-fileuploader-video-handler', + icon: 'videocam', + action: (maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]) => { + return this.uploaderHelper.uploadAudioOrVideo(false, maxSize, upload, mimetypes).then((result) => { + return { + treated: true, + result: result + }; + }); + } + }; + } +} diff --git a/src/core/login/pages/email-signup/email-signup.ts b/src/core/login/pages/email-signup/email-signup.ts index dcf5a0f2e..95504c0fe 100644 --- a/src/core/login/pages/email-signup/email-signup.ts +++ b/src/core/login/pages/email-signup/email-signup.ts @@ -236,7 +236,7 @@ export class CoreLoginEmailSignupPage { if (result.success) { // Show alert and ho back. let message = this.translate.instant('core.login.emailconfirmsent', {$a: params.email}); - this.domUtils.showAlert('core.success', message); + this.domUtils.showAlert(this.translate.instant('core.success'), message); this.navCtrl.pop(); } else { if (result.warnings && result.warnings.length) { diff --git a/src/core/mainmenu/pages/menu/menu.ts b/src/core/mainmenu/pages/menu/menu.ts index 3e0091f55..fad067dc5 100644 --- a/src/core/mainmenu/pages/menu/menu.ts +++ b/src/core/mainmenu/pages/menu/menu.ts @@ -80,7 +80,7 @@ export class CoreMainMenuPage implements OnDestroy { } this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { - this.tabs = handlers.slice(0, CoreMainMenuProvider.NUM_MAIN_HANDLERS); // Get main handlers. + handlers = handlers.slice(0, CoreMainMenuProvider.NUM_MAIN_HANDLERS); // Get main handlers. // Check if handlers are already in tabs. Add the ones that aren't. // @todo: https://github.com/ionic-team/ionic/issues/13633 diff --git a/src/core/sharedfiles/lang/en.json b/src/core/sharedfiles/lang/en.json new file mode 100644 index 000000000..c7ba93a40 --- /dev/null +++ b/src/core/sharedfiles/lang/en.json @@ -0,0 +1,11 @@ +{ + "chooseaccountstorefile": "Choose an account to store the file in.", + "chooseactionrepeatedfile": "A file with this name already exists. Do you want to replace the existing file or rename it to \"{{$a}}\"?", + "errorreceivefilenosites": "There are no sites stored. Please add a site before sharing a file with the app.", + "nosharedfiles": "There are no shared files stored on this site.", + "nosharedfilestoupload": "You have no files to upload here. If you want to upload a file from another app, locate the file and click the 'Open in' button.", + "rename": "Rename", + "replace": "Replace", + "sharedfiles": "Shared files", + "successstorefile": "File successfully stored. Select the file to upload to your private files or use in an activity." +} \ No newline at end of file diff --git a/src/core/sharedfiles/pages/choose-site/choose-site.html b/src/core/sharedfiles/pages/choose-site/choose-site.html new file mode 100644 index 000000000..cb275173d --- /dev/null +++ b/src/core/sharedfiles/pages/choose-site/choose-site.html @@ -0,0 +1,24 @@ + + + {{ 'core.sharedfiles.sharedfiles' | translate }} + + + + + + +

{{ 'core.sharedfiles.chooseaccountstorefile' | translate }}

+

{{fileName}}

+
+ + +

{{site.fullName}}

+

+

{{site.siteUrl}}

+
+
+
+
+ + + diff --git a/src/core/sharedfiles/pages/choose-site/choose-site.module.ts b/src/core/sharedfiles/pages/choose-site/choose-site.module.ts new file mode 100644 index 000000000..2436ede4b --- /dev/null +++ b/src/core/sharedfiles/pages/choose-site/choose-site.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 { CoreSharedFilesChooseSitePage } from './choose-site'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; + +@NgModule({ + declarations: [ + CoreSharedFilesChooseSitePage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreSharedFilesChooseSitePage), + TranslateModule.forChild() + ] +}) +export class CoreSharedFilesChooseSitePageModule {} diff --git a/src/core/sharedfiles/pages/choose-site/choose-site.ts b/src/core/sharedfiles/pages/choose-site/choose-site.ts new file mode 100644 index 000000000..68896cc94 --- /dev/null +++ b/src/core/sharedfiles/pages/choose-site/choose-site.ts @@ -0,0 +1,89 @@ +// (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 } from '@angular/core'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { CoreFileProvider } from '../../../../providers/file'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreSharedFilesHelperProvider } from '../../providers/helper'; + +/** + * Modal to display the list of sites to choose one to store a shared file. + */ +@IonicPage() +@Component({ + selector: 'page-core-shared-files-choose-site', + templateUrl: 'choose-site.html', +}) +export class CoreSharedFilesChooseSitePage implements OnInit { + + fileName: string; + sites: any[]; + loaded: boolean; + + protected filePath: string; + protected fileEntry: any; + + constructor(private navCtrl: NavController, navParams: NavParams, private sharedFilesHelper: CoreSharedFilesHelperProvider, + private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, + private fileProvider: CoreFileProvider) { + this.filePath = navParams.get('filePath'); + } + + /** + * Component being initialized. + */ + ngOnInit() { + if (!this.filePath) { + this.domUtils.showErrorModal('Error reading file.'); + this.navCtrl.pop(); + return; + } + + let fileAndDir = this.fileProvider.getFileAndDirectoryFromPath(this.filePath); + this.fileName = fileAndDir.name; + + // Get the file. + this.fileProvider.getFile(this.filePath).then((fe) => { + this.fileEntry = fe; + this.fileName = this.fileEntry.name; + }).catch(() => { + this.domUtils.showErrorModal('Error reading file.'); + this.navCtrl.pop(); + }); + + // Get the sites. + this.sitesProvider.getSites().then((sites) => { + this.sites = sites; + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Store the file in a certain site. + * + * @param {string} siteId Site ID. + */ + storeInSite(siteId: string) : void { + this.loaded = false; + this.sharedFilesHelper.storeSharedFileInSite(this.fileEntry, siteId).then(() => { + this.navCtrl.pop(); + }).finally(() => { + this.loaded = true; + }); + }; + +} \ No newline at end of file diff --git a/src/core/sharedfiles/pages/list/list.html b/src/core/sharedfiles/pages/list/list.html new file mode 100644 index 000000000..ee14a99a6 --- /dev/null +++ b/src/core/sharedfiles/pages/list/list.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/sharedfiles/pages/list/list.module.ts b/src/core/sharedfiles/pages/list/list.module.ts new file mode 100644 index 000000000..af48247d6 --- /dev/null +++ b/src/core/sharedfiles/pages/list/list.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 { CoreSharedFilesListPage } from './list'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; + +@NgModule({ + declarations: [ + CoreSharedFilesListPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreSharedFilesListPage), + TranslateModule.forChild() + ] +}) +export class CoreSharedFilesListPageModule {} diff --git a/src/core/sharedfiles/pages/list/list.ts b/src/core/sharedfiles/pages/list/list.ts new file mode 100644 index 000000000..dc285979e --- /dev/null +++ b/src/core/sharedfiles/pages/list/list.ts @@ -0,0 +1,181 @@ +// (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 } from '@angular/core'; +import { IonicPage, ViewController, NavParams, NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreFileProvider } from '../../../../providers/file'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreSharedFilesProvider } from '../../providers/sharedfiles'; + +/** + * Modal to display the list of shared files. + */ +@IonicPage() +@Component({ + selector: 'page-core-shared-files-list', + templateUrl: 'list.html', +}) +export class CoreSharedFilesListPage implements OnInit, OnDestroy { + + siteId: string; + isModal: boolean; + manage: boolean; + pick: boolean; // To pick a file you MUST use a modal. + path: string = ''; + title: string; + filesLoaded: boolean; + files: any[]; + + protected mimetypes: string[]; + protected shareObserver; + + constructor(private viewCtrl: ViewController, navParams: NavParams, private sharedFilesProvider: CoreSharedFilesProvider, + private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, private translate: TranslateService, + private fileProvider: CoreFileProvider, private eventsProvider: CoreEventsProvider, private navCtrl: NavController) { + this.siteId = navParams.get('siteId') || this.sitesProvider.getCurrentSiteId(); + this.mimetypes = navParams.get('mimetypes'); + this.isModal = !!navParams.get('isModal'); + this.manage = !!navParams.get('manage'); + this.pick = !!navParams.get('pick'); + this.path = navParams.get('path') || ''; + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.loadFiles(); + + // Listen for new files shared with the app. + this.shareObserver = this.eventsProvider.on(CoreEventsProvider.FILE_SHARED, (data) => { + if (data.siteId == this.siteId) { + // File was stored in current site, refresh the list. + this.filesLoaded = false; + this.loadFiles().finally(() => { + this.filesLoaded = true; + }); + } + }); + } + + /** + * Load the files. + */ + protected loadFiles() { + if (this.path) { + this.title = this.fileProvider.getFileAndDirectoryFromPath(this.path).name; + } else { + this.title = this.translate.instant('core.sharedfiles.sharedfiles'); + } + + return this.sharedFilesProvider.getSiteSharedFiles(this.siteId, this.path, this.mimetypes).then((files) => { + this.files = files; + this.filesLoaded = true; + }); + } + + /** + * Close modal. + */ + closeModal() : void { + this.viewCtrl.dismiss(); + } + + /** + * Refresh the list of files. + * + * @param {any} refresher Refresher. + */ + refreshFiles(refresher: any) : void { + this.loadFiles().finally(() => { + refresher.complete(); + }); + } + + /** + * Called when a file is deleted. Remove the file from the list. + * + * @param {number} index Position of the file. + */ + fileDeleted(index: number) : void { + this.files.splice(index, 1); + } + + /** + * Called when a file is renamed. Update the list. + * + * @param {number} index Position of the file. + * @param {any} file New FileEntry. + */ + fileRenamed(index: number, file: any) : void { + this.files[index] = file; + } + + /** + * Open a subfolder. + * + * @param {any} folder The folder to open. + */ + openFolder(folder: any) : void { + let path = this.textUtils.concatenatePaths(this.path, folder.name); + if (this.isModal) { + // In Modal we don't want to open a new page because we cannot dismiss the modal from the new page. + this.path = path; + this.filesLoaded = false; + this.loadFiles(); + } else { + this.navCtrl.push('CoreSharedFilesListPage', { + path: path, + manage: this.manage, + pick: this.pick, + siteId: this.siteId, + mimetypes: this.mimetypes, + isModal: this.isModal + }); + } + } + + /** + * Change site loaded. + * + * @param {string} id Site to load. + */ + changeSite(id: string) : void { + this.siteId = id; + this.path = ''; + this.filesLoaded = false; + this.loadFiles(); + } + + /** + * A file was picked. + * + * @param {any} file Picked file. + */ + filePicked(file: any) : void { + this.viewCtrl.dismiss(file); + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + if (this.shareObserver) { + this.shareObserver.off(); + } + } +} \ No newline at end of file diff --git a/src/core/sharedfiles/providers/helper.ts b/src/core/sharedfiles/providers/helper.ts new file mode 100644 index 000000000..22f76e6b7 --- /dev/null +++ b/src/core/sharedfiles/providers/helper.ts @@ -0,0 +1,176 @@ +// (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 { AlertController, ModalController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreInitDelegate } from '../../../providers/init'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreSharedFilesProvider } from './sharedfiles'; +import { CoreFileUploaderProvider } from '../../fileuploader/providers/fileuploader'; + +/** + * Helper service to share files with the app. + */ +@Injectable() +export class CoreSharedFilesHelperProvider { + protected logger; + + constructor(logger: CoreLoggerProvider, private alertCtrl: AlertController, private translate: TranslateService, + private utils: CoreUtilsProvider, private sitesProvider: CoreSitesProvider, private modalCtrl: ModalController, + private fileUploaderProvider: CoreFileUploaderProvider, private initDelegate: CoreInitDelegate, + private sharedFilesProvider: CoreSharedFilesProvider, private domUtils: CoreDomUtilsProvider, + private fileProvider: CoreFileProvider, private appProvider: CoreAppProvider) { + this.logger = logger.getInstance('CoreSharedFilesHelperProvider'); + } + + /** + * Ask a user if he wants to replace a file (using originalName) or rename it (using newName). + * + * @param {string} originalName Original name. + * @param {string} newName New name. + * @return {Promise} Promise resolved with the name to use when the user chooses. Rejected if user cancels. + */ + askRenameReplace(originalName: string, newName: string) : Promise { + const deferred = this.utils.promiseDefer(), + alert = this.alertCtrl.create({ + title: this.translate.instant('core.sharedfiles.sharedfiles'), + message: this.translate.instant('core.sharedfiles.chooseactionrepeatedfile', {$a: newName}), + buttons: [ + { + text: this.translate.instant('core.sharedfiles.rename'), + handler: () => { + deferred.resolve(newName); + } + }, + { + text: this.translate.instant('core.sharedfiles.replace'), + handler: () => { + deferred.resolve(originalName); + } + } + ] + }); + + alert.present(); + return deferred.promise; + } + + /** + * Go to the choose site view. + * + * @param {string} filePath File path to send to the view. + */ + goToChooseSite(filePath: string) : void { + let navCtrl = this.appProvider.getRootNavController(); + navCtrl.push('CoreSharedFilesChooseSitePage', {filePath: filePath}); + } + + /** + * Open the view to select a shared file. + * + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved when a file is picked, rejected if file picker is closed without selecting a file. + */ + pickSharedFile(mimetypes?: string[]) : Promise { + return new Promise((resolve, reject) => { + let modal = this.modalCtrl.create('CoreSharedFilesListPage', {mimetypes: mimetypes, isModal: true, pick: true}); + modal.present(); + + modal.onDidDismiss((file: any) => { + if (!file) { + // User cancelled. + reject(); + return; + } + + const error = this.fileUploaderProvider.isInvalidMimetype(mimetypes, file.fullPath); + if (error) { + reject(error); + } else { + resolve({ + path: file.fullPath, + treated: false + }); + } + }) + }); + } + + /** + * Checks if there is a new file received in iOS and move it to the shared folder of current site. + * If more than one site is found, the user will have to choose the site where to store it in. + * If more than one file is found, treat only the first one. + * + * @return {Promise} Promise resolved when done. + */ + searchIOSNewSharedFiles() : Promise { + return this.initDelegate.ready().then(() => { + let navCtrl = this.appProvider.getRootNavController(); + if (navCtrl && navCtrl.getActive().id == 'CoreSharedFilesChooseSite') { + // We're already treating a shared file. Abort. + return Promise.reject(null); + } + + return this.sharedFilesProvider.checkIOSNewFiles().then((fileEntry) => { + return this.sitesProvider.getSitesIds().then((siteIds) => { + if (!siteIds.length) { + // No sites stored, show error and delete the file. + this.domUtils.showErrorModal('core.sharedfiles.errorreceivefilenosites', true); + return this.sharedFilesProvider.deleteInboxFile(fileEntry); + } else if (siteIds.length == 1) { + return this.storeSharedFileInSite(fileEntry, siteIds[0]); + } else { + this.goToChooseSite(fileEntry.fullPath); + } + }); + }); + }); + } + + /** + * Store a shared file in a site's shared files folder. + * + * @param {any} fileEntry Shared file entry. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + storeSharedFileInSite(fileEntry: any, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // First of all check if there's already a file with the same name in the shared files folder. + const sharedFilesDirPath = this.sharedFilesProvider.getSiteSharedFilesDirPath(siteId); + return this.fileProvider.getUniqueNameInFolder(sharedFilesDirPath, fileEntry.name).then((newName) => { + if (newName == fileEntry.name) { + // No file with the same name. Use the original file name. + return newName; + } else { + // Repeated name. Ask the user what he wants to do. + return this.askRenameReplace(fileEntry.name, newName); + } + }).then((name) => { + return this.sharedFilesProvider.storeFileInSite(fileEntry, name, siteId).catch(function(err) { + this.domUtils.showErrorModal(err || 'Error moving file.'); + }).finally(() => { + this.sharedFilesProvider.deleteInboxFile(fileEntry); + this.domUtils.showAlertTranslated('core.success', 'core.sharedfiles.successstorefile'); + }); + }); + } +} diff --git a/src/core/sharedfiles/providers/sharedfiles.ts b/src/core/sharedfiles/providers/sharedfiles.ts new file mode 100644 index 000000000..5a653f442 --- /dev/null +++ b/src/core/sharedfiles/providers/sharedfiles.ts @@ -0,0 +1,243 @@ +// (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 { CoreAppProvider } from '../../../providers/app'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreMimetypeUtilsProvider } from '../../../providers/utils/mimetype'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; +import { Md5 } from 'ts-md5/dist/md5'; +import { SQLiteDB } from '../../../classes/sqlitedb'; + +/** + * Service to share files with the app. + */ +@Injectable() +export class CoreSharedFilesProvider { + public static SHARED_FILES_FOLDER = 'sharedfiles'; + + // Variables for the database. + protected SHARED_FILES_TABLE = 'wscache'; + protected tableSchema = { + name: this.SHARED_FILES_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + } + ] + } + + protected logger; + protected appDB: SQLiteDB; + + constructor(logger: CoreLoggerProvider, private fileProvider: CoreFileProvider, appProvider: CoreAppProvider, + private textUtils: CoreTextUtilsProvider, private mimeUtils: CoreMimetypeUtilsProvider, + private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider) { + this.logger = logger.getInstance('CoreSharedFilesProvider'); + + this.appDB = appProvider.getDB(); + this.appDB.createTableFromSchema(this.tableSchema); + } + + /** + * Checks if there is a new file received in iOS. If more than one file is found, treat only the first one. + * The file returned is marked as "treated" and will be deleted in the next execution. + * + * @return {Promise} Promise resolved with a new file to be treated. If no new files found, promise is rejected. + */ + checkIOSNewFiles() : Promise { + this.logger.debug('Search for new files on iOS'); + return this.fileProvider.getDirectoryContents('Inbox').then((entries) => { + if (entries.length > 0) { + let promises = [], + fileToReturn; + + entries.forEach((entry) => { + const fileId = this.getFileId(entry); + + // Check if file was already treated. + promises.push(this.isFileTreated(fileId).then(() => { + // File already treated, delete it. Don't return delete promise, we'll ignore errors. + this.deleteInboxFile(entry); + }).catch(() => { + // File not treated before. + this.logger.debug('Found new file ' + entry.name + ' shared with the app.'); + if (!fileToReturn) { + fileToReturn = entry; + } + })); + }); + + return Promise.all(promises).then(() => { + let fileId; + + if (fileToReturn) { + // Mark it as "treated". + fileId = this.getFileId(fileToReturn); + return this.markAsTreated(fileId).then(() => { + this.logger.debug('File marked as "treated": ' + fileToReturn.name); + return fileToReturn; + }); + } else { + return Promise.reject(null); + } + }); + } else { + return Promise.reject(null); + } + }); + } + + /** + * Deletes a file in the Inbox folder (shared with the app). + * + * @param {any} entry FileEntry. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + deleteInboxFile(entry: any) : Promise { + this.logger.debug('Delete inbox file: ' + entry.name); + + return this.fileProvider.removeFileByFileEntry(entry).catch(() => { + // Ignore errors. + }).then(() => { + return this.unmarkAsTreated(this.getFileId(entry)).then(() => { + this.logger.debug('"Treated" mark removed from file: ' + entry.name); + }).catch((error) => { + this.logger.debug('Error deleting "treated" mark from file: ' + entry.name, error); + return Promise.reject(error); + }); + }); + } + + /** + * Get the ID of a file for managing "treated" files. + * + * @param {any} entry FileEntry. + * @return {string} File ID. + */ + protected getFileId(entry: any) : string { + return Md5.hashAsciiStr(entry.name); + } + + /** + * Get the shared files stored in a site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {string} [path] Path to search inside the site shared folder. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved with the files. + */ + getSiteSharedFiles(siteId?: string, path?: string, mimetypes?: string[]) : Promise { + let pathToGet = this.getSiteSharedFilesDirPath(siteId); + if (path) { + pathToGet = this.textUtils.concatenatePaths(pathToGet, path); + } + + return this.fileProvider.getDirectoryContents(pathToGet).then((files) => { + if (mimetypes) { + // Only show files with the right mimetype and the ones we cannot determine the mimetype. + files = files.filter((file) => { + const extension = this.mimeUtils.getFileExtension(file.name), + mimetype = this.mimeUtils.getMimeType(extension); + + return !mimetype || mimetypes.indexOf(mimetype) > -1; + }); + } + + return files; + }).catch(() => { + // Directory not found, return empty list. + return []; + }); + } + + /** + * Get the path to a site's shared files folder. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {string} Path. + */ + getSiteSharedFilesDirPath(siteId?: string) : string { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.fileProvider.getSiteFolder(siteId) + '/' + CoreSharedFilesProvider.SHARED_FILES_FOLDER; + } + + /** + * Check if a file has been treated already. + * + * @param {string} fileId File ID. + * @return {Promise} Resolved if treated, rejected otherwise. + */ + protected isFileTreated(fileId: string) : Promise { + return this.appDB.getRecord(this.SHARED_FILES_TABLE, {id: fileId}); + } + + /** + * Mark a file as treated. + * + * @param {string} fileId File ID. + * @return {Promise} Promise resolved when marked. + */ + protected markAsTreated(fileId: string) : Promise { + // Check if it's already marked. + return this.isFileTreated(fileId).catch(() => { + // Doesn't exist, insert it. + return this.appDB.insertRecord(this.SHARED_FILES_TABLE, {id: fileId}); + }); + } + + /** + * Store a file in a site's shared folder. + * + * @param {any} entry File entry. + * @param {string} [newName] Name of the new file. If not defined, use original file's name. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise}Promise resolved when done. + */ + storeFileInSite(entry: any, newName?: string, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!entry || !siteId) { + return Promise.reject(null); + } + + newName = newName || entry.name; + + const sharedFilesFolder = this.getSiteSharedFilesDirPath(siteId), + newPath = this.textUtils.concatenatePaths(sharedFilesFolder, newName); + + // Create dir if it doesn't exist already. + return this.fileProvider.createDir(sharedFilesFolder).then(() => { + return this.fileProvider.moveFile(entry.fullPath, newPath).then((newFile) => { + this.eventsProvider.trigger(CoreEventsProvider.FILE_SHARED, {siteId: siteId, name: newName}); + return newFile; + }); + }); + } + + /** + * Unmark a file as treated. + * + * @param {string} fileId File ID. + * @return {Promise} Resolved when unmarked. + */ + protected unmarkAsTreated(fileId: string) : Promise { + return this.appDB.deleteRecords(this.SHARED_FILES_TABLE, {id: fileId}); + } +} diff --git a/src/core/sharedfiles/providers/upload-handler.ts b/src/core/sharedfiles/providers/upload-handler.ts new file mode 100644 index 000000000..1337dd4f2 --- /dev/null +++ b/src/core/sharedfiles/providers/upload-handler.ts @@ -0,0 +1,64 @@ +// (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 { Platform } from 'ionic-angular'; +import { CoreFileUploaderHandler, CoreFileUploaderHandlerData } from '../../fileuploader/providers/delegate'; +import { CoreSharedFilesHelperProvider } from './helper'; +/** + * Handler to upload files from the album. + */ +@Injectable() +export class CoreSharedFilesUploadHandler implements CoreFileUploaderHandler { + name = 'CoreSharedFilesUpload'; + priority = 1300; + + constructor(private sharedFilesHelper: CoreSharedFilesHelperProvider, private platform: Platform) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise { + return this.platform.is('ios'); + } + + /** + * Given a list of mimetypes, return the ones that are supported by the handler. + * + * @param {string[]} [mimetypes] List of mimetypes. + * @return {string[]} Supported mimetypes. + */ + getSupportedMimetypes(mimetypes: string[]) : string[] { + return mimetypes; + } + + /** + * Get the data to display the handler. + * + * @return {CoreFileUploaderHandlerData} Data. + */ + getData() : CoreFileUploaderHandlerData { + return { + title: 'core.sharedfiles.sharedfiles', + class: 'core-sharedfiles-fileuploader-handler', + icon: 'folder', + action: (maxSize?: number, upload?: boolean, allowOffline?: boolean, mimetypes?: string[]) => { + // Don't use the params because the file won't be uploaded, it is returned to the fileuploader. + return this.sharedFilesHelper.pickSharedFile(mimetypes); + } + }; + } +} diff --git a/src/core/sharedfiles/sharedfiles.module.ts b/src/core/sharedfiles/sharedfiles.module.ts new file mode 100644 index 000000000..e58bbd904 --- /dev/null +++ b/src/core/sharedfiles/sharedfiles.module.ts @@ -0,0 +1,47 @@ +// (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 { Platform } from 'ionic-angular'; +import { CoreSharedFilesProvider } from './providers/sharedfiles'; +import { CoreSharedFilesHelperProvider } from './providers/helper'; +import { CoreSharedFilesUploadHandler } from './providers/upload-handler'; +import { CoreFileUploaderDelegate } from '../fileuploader/providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + CoreSharedFilesProvider, + CoreSharedFilesHelperProvider, + CoreSharedFilesUploadHandler + ] +}) +export class CoreSharedFilesModule { + constructor(platform: Platform, delegate: CoreFileUploaderDelegate, handler: CoreSharedFilesUploadHandler, + helper: CoreSharedFilesHelperProvider) { + // Register the handler. + delegate.registerHandler(handler); + + if (platform.is('ios')) { + // Check if there are new files at app start and when the app is resumed. + helper.searchIOSNewSharedFiles(); + platform.resume.subscribe(() => { + helper.searchIOSNewSharedFiles(); + }); + } + } +} diff --git a/src/pipes/seconds-to-hms.ts b/src/pipes/seconds-to-hms.ts index c88164cbc..8160522a2 100644 --- a/src/pipes/seconds-to-hms.ts +++ b/src/pipes/seconds-to-hms.ts @@ -54,6 +54,9 @@ export class CoreSecondsToHMSPipe implements PipeTransform { seconds = numberSeconds; } + // Don't allow decimals. + seconds = Math.floor(seconds); + hours = Math.floor(seconds / CoreConstants.secondsHour); seconds -= hours * CoreConstants.secondsHour; minutes = Math.floor(seconds / CoreConstants.secondsMinute); diff --git a/src/providers/events.ts b/src/providers/events.ts index 27aeb835a..e9066b00f 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -46,6 +46,7 @@ export class CoreEventsProvider { public static IAB_LOAD_START = 'inappbrowser_load_start'; public static IAB_EXIT = 'inappbrowser_exit'; public static APP_LAUNCHED_URL = 'app_launched_url'; // App opened with a certain URL (custom URL scheme). + public static FILE_SHARED = 'file_shared'; logger; observables: {[s: string] : Subject} = {}; diff --git a/src/providers/file.ts b/src/providers/file.ts index bfe7f9c4c..e8c693f8a 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -457,7 +457,7 @@ export class CoreFileProvider { // Create file (and parent folders) to prevent errors. return this.createFile(path).then((fileEntry) => { - if (this.isHTMLAPI && this.appProvider.isDesktop() && + if (this.isHTMLAPI && !this.appProvider.isDesktop() && (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { // We need to write Blobs. let type = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(path)); @@ -832,7 +832,7 @@ export class CoreFileProvider { * @param {string} [defaultExt] Default extension to use if no extension found in the file. * @return {Promise} Promise resolved with the unique file name. */ - getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt: string) : Promise { + getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string) : Promise { // Get existing files in the folder. return this.getDirectoryContents(dirPath).then((entries) => { let files = {}, @@ -923,4 +923,14 @@ export class CoreFileProvider { // Ignore errors, maybe it doesn't exist. }); } + + /** + * Check if a file is inside the app's folder. + * + * @param {string} path The absolute path of the file to check. + * @return {boolean} Whether the file is in the app's folder. + */ + isFileInAppFolder(path: string) : boolean { + return path.indexOf(this.basePath) != -1; + } } diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 9f077a25a..d75334f2d 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -259,6 +259,33 @@ export class CoreUtilsProvider { } } + /** + * Clone a variable. It should be an object, array or primitive type. + * + * @param {any} source The variable to clone. + * @return {any} Cloned variable. + */ + clone(source: any) : any { + if (Array.isArray(source)) { + // Clone the array and all the entries. + let newArray = []; + for (let i = 0; i < source.length; i++) { + newArray[i] = this.clone(source[i]); + } + return newArray; + } else if (typeof source == 'object') { + // Clone the object and all the subproperties. + let newObject = {}; + for (let name in source) { + newObject[name] = this.clone(source[name]); + } + return newObject; + } else { + // Primitive type or unknown, return it as it is. + return source; + } + } + /** * Copy properties from one object to another. *