From 730571ed83c3f39521617f677c710c39505aa078 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 16 Jan 2023 12:32:35 +0100 Subject: [PATCH 01/80] MOBILE-4069 book: Add basic usage behat tests --- .../mod/book/tests/behat/basic_usage.feature | 191 ++++++++++++++++++ src/testing/services/behat-dom.ts | 8 + 2 files changed, 199 insertions(+) create mode 100755 src/addons/mod/book/tests/behat/basic_usage.feature diff --git a/src/addons/mod/book/tests/behat/basic_usage.feature b/src/addons/mod/book/tests/behat/basic_usage.feature new file mode 100755 index 00000000000..6e24b7b2a27 --- /dev/null +++ b/src/addons/mod/book/tests/behat/basic_usage.feature @@ -0,0 +1,191 @@ +@mod @mod_book @app @javascript +Feature: Test basic usage of book activity in app + In order to view a book while using the mobile app + As a student + I need basic book functionality to work + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | teacher | teacher1@example.com | + | student1 | Student | student | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | intro | course | idnumber | numbering | + | book | Basic book | Test book description | C1 | book | 1 | + And the following "mod_book > chapter" exist: + | book | title | content | subchapter | hidden | pagenum | + | Basic book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | Basic book | Chapt 1.1 | This is a subchapter | 1 | 0 | 2 | + | Basic book | Chapt 2 | This is the second chapter | 0 | 0 | 3 | + | Basic book | Hidden chapter | This is a hidden chapter | 0 | 1 | 4 | + | Basic book | Hidden subchapter | This is a hidden subchapter | 1 | 1 | 5 | + | Basic book | Chapt 3 | This is the third chapter | 0 | 0 | 6 | + + Scenario: View book table of contents (student) + Given I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + Then I should find "Test book description" in the app + And I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Chapt 3" in the app + And I should find "Start" in the app + But I should not find "Hidden chapter" in the app + And I should not find "Hidden subchapter" in the app + And I should not find "This is the first chapter" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Chapt 3" in the app + But I should not find "Hidden chapter" in the app + And I should not find "Hidden subchapter" in the app + + Scenario: View book table of contents (teacher) + Given I entered the course "Course 1" as "teacher1" in the app + And I press "Basic book" in the app + Then I should find "Test book description" in the app + And I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Hidden chapter" in the app + And I should find "Hidden subchapter" in the app + And I should find "Chapt 3" in the app + And I should find "Start" in the app + And I should not find "This is the first chapter" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Hidden chapter" in the app + And I should find "Hidden subchapter" in the app + And I should find "Chapt 3" in the app + + Scenario: Open chapters from table of contents + Given I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + When I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + But I should not find "This is the second chapter" in the app + + When I press the back button in the app + And I press "Chapt 2" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + But I should not find "This is the first chapter" in the app + + Scenario: View and navigate book contents (student) + Given I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + And I press "Start" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 4" in the app + + When I press "Next" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 4" in the app + But I should not find "This is the first chapter" in the app + + When I press "Next" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 4" in the app + But I should not find "This is a subchapter" in the app + + When I press "Previous" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 4" in the app + But I should not find "This is the second chapter" in the app + + # Navigate using TOC. + When I press "Table of contents" in the app + And I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 4" in the app + But I should not find "This is a subchapter" in the app + + When I press "Table of contents" in the app + And I press "Chapt 3" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "4 / 4" in the app + But I should not find "This is the first chapter" in the app + + # TODO: Test navigate using swipe. + +Scenario: View and navigate book contents (teacher) + Given I entered the course "Course 1" as "teacher1" in the app + And I press "Basic book" in the app + And I press "Start" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 6" in the app + + When I press "Next" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 6" in the app + But I should not find "This is the first chapter" in the app + + When I press "Next" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 6" in the app + But I should not find "This is a subchapter" in the app + + When I press "Next" in the app + Then I should find "Hidden chapter" in the app + And I should find "This is a hidden chapter" in the app + And I should find "4 / 6" in the app + But I should not find "This is the second chapter" in the app + + When I press "Next" in the app + Then I should find "Hidden subchapter" in the app + And I should find "This is a hidden subchapter" in the app + And I should find "5 / 6" in the app + But I should not find "This is a hidden chapter" in the app + + When I press "Previous" in the app + Then I should find "Hidden chapter" in the app + And I should find "This is a hidden chapter" in the app + And I should find "4 / 6" in the app + But I should not find "This is a hidden subchapter" in the app + + # Navigate using TOC. + When I press "Table of contents" in the app + And I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 6" in the app + But I should not find "This is a hidden chapter" in the app + + When I press "Table of contents" in the app + And I press "Hidden subchapter" in the app + Then I should find "Hidden subchapter" in the app + And I should find "This is a hidden subchapter" in the app + And I should find "5 / 6" in the app + But I should not find "This is the first chapter" in the app + + # TODO: Test navigate using swipe. + + Scenario: Link to book opens chapter content + Given I entered the book activity "Basic book" on course "Course 1" as "student1" in the app + Then I should find "This is the first chapter" in the app + + # TODO: Scenario to test book numbering (numbers, bullets, etc.). diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index b43193e3edf..28fd807f554 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -46,6 +46,14 @@ export class TestingBehatDomUtilsService { return false; } + if (element.tagName === 'ION-SLIDE') { + // Check if the slide is visible (in the viewport). + const bounding = element.getBoundingClientRect(); + if (bounding.right <= 0 || bounding.left >= window.innerWidth) { + return false; + } + } + if (!container) { return true; } From ed6cce66b55d0d0dad65f9c12264a6cdf7b1c7d0 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 19 Jan 2023 15:47:05 +0100 Subject: [PATCH 02/80] MOBILE-4235 h5p: Fix regex applied by dependencies --- src/core/features/h5p/classes/core.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/features/h5p/classes/core.ts b/src/core/features/h5p/classes/core.ts index 6bdd1de7494..c99712a9d20 100644 --- a/src/core/features/h5p/classes/core.ts +++ b/src/core/features/h5p/classes/core.ts @@ -271,8 +271,13 @@ export class CoreH5PCore { if (addon.addTo?.content?.types?.length) { for (let i = 0; i < addon.addTo.content.types.length; i++) { const type = addon.addTo.content.types[i]; + let regex = type?.text?.regex; + if (regex && regex[0] === '/' && regex.slice(-1) === '/') { + // Regex designed for PHP. Remove the starting and ending slashes to convert them to JS format. + regex = regex.substring(1, regex.length - 1); + } - if (type && type.text && type.text.regex && this.textAddonMatches(params.params, type.text.regex)) { + if (regex && this.textAddonMatches(params.params, regex)) { await validator.addon(addon); // An addon shall only be added once. From c2753debdc2f6e9a1105d3c8b1232d67a3d1182c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 20 Jan 2023 13:05:15 +0100 Subject: [PATCH 03/80] MOBILE-4236 assets: Add TM to Moodle Logo --- resources/splash.png | Bin 55907 -> 58454 bytes src/assets/img/login_logo.png | Bin 16563 -> 15171 bytes src/assets/img/top_logo.png | Bin 16563 -> 15171 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/splash.png b/resources/splash.png index e7889ccf91e612b8a62d5ff911239c96d31360fd..d44505493ca895f5582ce99702335d2ce402c72f 100644 GIT binary patch literal 58454 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%0A!i9oRVgR*4}#IvT_T5brCywy_p?P0PCAOyJ1R6 zf0d|0D1kC9I!(I-c6I?RgFHWqL4CX>B9ZCE_;!yjx95ih8h&4oP)|jFkj^8nGHHe! zHqQ2g2Kl8StDe1^Rfu;YkvWFDPw0McLcI%5?s>~37F<(J zK}GYo?!qQ?n~ZNg;#zqa^q4J4J7i>-F)+@Jj%hv2P^vsEP#K};H<*-{9&*{!8nlmZ zG9-*|Qs+mW>}91crO#K1((&gQzN*m^BUxHE#@o?~f+kAd49)@L6M_*o%-c!VL;xLE zCYSemqzWB~q={QfQwn84xJVVt1S;8kY>iztEKKcX__rQGkjUiH3k{K11}d*+ z%mC+fGx3Dv^y|fKx?8zXgws%J$9&>loX7EQP7Zzz-)tL|G7^pfrnpp0QE5L}+daP> z-R43Xa>ca02v$teQe&GYf=!mIU8^j2Ob18CgyPHDSUybhiftz49Nqi0scig2JwoKJ zpHqF!0f?ZAd)z3`E#)PG8}ued9_HU+$jl};+1?j&g6dPu6@rn8zJTqMB&Hxll!7Mo zVUOBA$u~Y(U6Sw(NvY;?k5h^X#BIeZ7n3bxp5ohY(_KnT1qB#OU=6&KCsn{;#FC)u z6Lh-oMF3Y?qT|Zik4LY~*69{C*gV0vo1R%lUJTrgiADOXMCZ_ZyjGa`ONjht(mV2?7p%p#&-c*EjJRoi6rO z_3~|i?ejxL4^V0kdNV|J-S@U=UmKF9nXD$yJfB|ML11dj4a_4MWpB|s^pHkp@!kOa z=%M<&$`6sw#`o7-0qxUXlFjId!3fSYDG1PvvOvpjd8_F?s2stRCCx1W$-pMv+*_7_ z{8%dk%w&ADp2w+EmkT3voR`5}ffA>UYNlw#O~LiNn(h_4R%wc3Bnb(EPW4EXhW~U? z6{pr`v?*G*NNl_eB}sfa54>MCq0N;Zw(P3qFUmkQ>-)9MQHjaOLXJ1yyezG!wev?5 zyAiEW+L0f1^n~u{qkzt}_5d3JQ%+a_Z;8PSC3TsHo#Pc!bN4320sn_q3~~m`d~bq! zRY0NBg4=6ohC+EcVOOu#Qd%c+#hjS1WA+KYAL}cVjeA2W_XzfIISN;DsBz*QPz`Sy zJgNO`zKU+b_Vk_{5Q~EtLfhJp&je=KZ(jc$J4@3-mk`FlMj5Tolm%@os2U3K)vTE* z4IS6;M34@ZhH~FBikOAmqX=_l*fj7nJKI|MslhR8-{C+cnv5N;rlthIJ(RA z%l*a1@S+A=gi9Jl*4i}M$3?@&+P5)ZZa{c0QZx~>gDB$O8w_${tIa#oCvqw-5?nNT z@`C$(Pmybp-5+NHY(i{})aqajaA8QKZ~-e<$BRK;!ZYYx3Yl(;ZOlg(2cLAg^K<}X zqW}lI$J&IoC1c^rXz=9YJN|;@UK9X{AxNOtp1tr&tfs18NO*K-!ntw9t$lW}y(V6H zO*7s&+Zzd&`}rU+xbg0}>oAL-;9r&5(*=g?{HB=*y5V?#fK9cY5?nBSkKZmYUc^0P ze(%F)YQ=i4Xob+W8|BezqpWPD?8_PP#tr_jQqRmJC5($+a$}gzYUd#q59feYA>s zUiA&@WXs;%>W{xr6$UCi2?&>?u&c$U6X{QOo}0@oW~XD_=S9*rS(kCs>Ce{5W-hI^ z7QU+pu`QW#1eYW0f?UnQmMZ$UuuZy>Dv-uA9wnwl5wRbRjtXq@;>B#W`iuZO(pcm^ zzaL3}_c;{+OK>(4^*xx=yi;CW3YQt~lZ0jC~B~j8G<~UGJhHJMsg14VxF<;+uKx8=&qzlZ-mZu*X zBAU#!*9z*}CzW_UOS15e$)mHalUXvTnXU|=Jgmec zDk%|p0wnS|=hw~xV73z^U62`@7VXK)J%$Np#H-#e>omoqo*5Y#B`X_p`@FCp!^eqL zK+j$bNdzZ#-l8D49XQ--bkSaT_?0Wo@p6I>JnglzS#Pn41x!bPib%!FRk7bUZ`~mR zOG~OY=q_voQOj7N@QA^I=wJ;}E|FB=>NT{WGjYUry*p7E(UiV&+AM6%Nls9shy*1` zTVKx+ImX&gW{M2bj@iEL!*i@(<{vT}+EmbOaO3E$1>mliIu$caoOEePWrw2;Thn=@ z5_YUw!+^G4xrx_+;y$9E&OpAAz&ttIe>SVvaJTzPUhtcFw1Pi>g(w4m5SKwX zs^rRPnQRC`AzMZ5lSetW@_BJiTeN8gbK_`*r3D=jWG6DSXh}hR9&o~qLk4S;S!IAV z+n7Js0ILri3TAdc{~QP>FoP&B4%p}G&*IibG0oHfcA4M4fl8dQhv1HV>-*?CS zZC`8OXslSSqn+W8*>d=)c_i|nm#v^-W$^;?V16>lF#>}%#~w0Yf3}^TRPrJ}-|jd_ zPFt4jaWdg1-BY>MH`+Dx`eUvVjkD@Q{_Yj)CkJ=GF?~|JObVkyWnSaw{*N4@utcq_W+K`VlAI zqF8Q_@xvWgTl?sFKRHGF(9-mT!9i;`UeBJIS8f3iTtLs?tBe5Tke+k>fNmS!ydP^t zLM~5rcTxnewjXU5&4s3p@lBXsGaj z%BT(AYq3j!YEP(=hK?Vg!<7Hr3Dm{j7)jN(ZPhjQqUZGurY|T_ff5xcQGpT_C{fv9 zxfiEuP@)1QD*xSyN>@;L^gpoxP(KUGa-b{+%5tDA2g-7wEJvK-Nq(RcIVjPE65apq zL|01c&rNP`_EmU^UFgjpggse(PWvV96he|gxHt$G2jSu%TpWapgK%-1*){kD;o=}% z9E6L5aB&bW?w`yJe{Q+5;ftX%A1d>qGXFmZypdQTG$sM1ga41y!AF7Yb7DI<|6Bd? zLGB)NiA_Mk@K0z=2X)ybN=a>rMV$JpE+Za~v`==j`+B_MAN!{8Urv@}--XNJvO7t0*gIlaO30 zJo&kJ1~|k2X;hJfpm#3_EFWPpyG(#C;r8nMBzy44F{>G?%{>_1PX;xHsN0({Y~dIm18NA1 zS(5%i_m3Z+CIY4*hnrTIcVX+$9{iI3B4P>WdN6YR&dA=|!{o53SrynCqE$W>8r4vI z)Eziv6_#3})gWaHci&z0Fy~ImpZ3q=SMv5zzg@jK|GC5I$l!2y0vg;x&8caTCQ)sx zEc3F?Ut5L8Thd+*s~*W4Q&--oo)NsL&Hhjck$1N(+P!R55(_QNwsh?1ZDujf*+l+2BX7d8K76ExfeceA+2k((tT170j$nEdrs-{zK?mI12rNf$^KEl?3?8vmk)kMzUxL4_J zF@v&u5h9WgNi@qvzrMOVE)`b$q1s;Ki)UoNSqNDcm5$$2v5y=(L+HVu+x+lpPFhNB zG>6Hdy#Q@fy48;hy2Y|ZT2A-ItzWj>7iqK0H#pxYrPD3(EDZW%-1~BgjEnzq%p&pmy!Ngvca_<&EFZ30?W!=bw7nR)_ktyFA9M zItyB!g||AjKw3U2&3n+>FQEi}iT~!-EGifofvW!Oysk@eXyTcbSyS}ZTl81qt%-DX zYbMJ2D7r9I-@31Q1?R-4sdbs+{uMU~r>ti_16)E}woa0B7ahFG-l^oK_S2ct8%3O% zvw5ju=bEHRVmjqK8^!L&vp*qGB;v^Wf$H^(99N?&jU*)tsKF$sc~Q>E%2!Q(3~sFq zW2-SS>}G>wj;1D3eV)P$@;4|Ec?5&!L!y2|Q{8!&-aO=Kqb*=H!K<5SCXp>p$IaKX zUjDpQUNkU*`YGNy@3i4FD?a0U73BBhsn*+1^e;n)RPx*f-AsjAdg5_;?3N>|dG6(hIXOBbZb1+iV={b( z03ykwT<+GNPa46wo7>poNs(AvvgJgRK=lLnpGRtkzT~a zJtvcpVg?$4;1ynC7kB$y&e(ZBm7wMmyg&LH?UI!3eg9=uvsbZL@y<%JiXX*#bxG>$ zx8jNAzoz{!eyUNGZGtTAhpE(mGP{s3(EUbzEQTEP9IhO3)vo?(`{E58V(y2eQnahP zXy)S%)=-kK_iuSIZx?Ys{$_EF>}J}`4aOhjk`AdQ0_UI4*?Jl-HNI%Nlw1o^K1*n{ zC0~Z~LDQH>i069B-EOb?Frl3APGK2lWx=(I0W`mzZ`(QfTJ(ejY11 z?H7jepr{_Q?|``;HySk?dg_0<7fw!UytR-zgzy3 zCCNV!k^Ivhl7D7{gyf&S{O2eD=lIVzGKl7FEAU=aUh6#qhle>vny z+Wapx_!k-gF!;wB{0j~Kg$5@E@&6JU81Al5W_`a$(zwL@Su*%m&{#-xG`u>*T|AH? zNI2;17{!ezXJjwZm(be9&vZ)cyu;s@t0uX6=YOe6>XRFv9120FEkKq&{$Hw+^}mxr zSO1s&y!hWqR)zn1|85Cn^Z#V|4@Cd82QUfL|IEfedkJLo{~X0X5BV?JILYS!g$Dl* zga({54pRTs0-OY{|CIBea{g1!6HWMkKso;@ON9Bmhu-1g=sZUNt}jQo>grP+Pj z*pR%jcPq|e*HcC4`8wv<2@wA$8n*w))RWZ^Q2p{hge&mR#(R%wPEP-KWS&#`uY1z|yBHAI z+lwaxe07v%>)OLk6Wq`s3&K{|4_^F>a2NAa0k=`)C$w7+EPHTAOYRUG8X9_fdMYa` zcXxN+zkeUesl^f5K3rfRx78(l{``3b%2P4#vqb#qoNJYU+PK3+6B+60m#|pu^71n9 zxwf{pvLd+?GBPsK($ez%`*$5!=N>@^nO9g?n4d4ya`o?G{i;*`L$V8)YC5fJ5fI16 z$Ls6sp`oF`V^hRkGfGPZqg8>STMNajbC8jd_1@oweOdG#HFfWf)ROha!bhKEKC`4a z9a0kA>0M>H^QVAHsG$fl@$jt7`L`24gZaqnDCov3igoXwZ*O>6%-W}sbu$m zWroR0TD4I2Ze0{Vy^+hEQ^8a_<|k<8YGHqcwaz9dC-d_1e*XL!4u>-`GUhhKsdM1* zcz$R-F>s+PerwDsY|^!rk)56W_J1(-6F(J^Uz{-FXmHs>OG`^hNy*pO*TlpG=mQ-c z9UU_>GcdTs6qtC(M+Mdb{i4GkbPeA;mIsvC9$TCahlEb?57b||zbqfu<-Q))N*k$YwY8fBpLPh5sNmv#@Y&`UCUvcCMU^_og+7H~H^QhUuk0 zBYIWZBe9sY<&VtGU7Tx=)X>lt1W6tvBu)UcK^Rnd|f>zsgS4`4f3=B|dj>89*C=l-kx*OC(8$@pS31PX=n@%2^eKoPl4 z&@FfL`vl#dE4rblSCWvtzw}2-8ZV~h_taPmc?0?Y2wVQc73_F(HN%=Z+WhX_sKs1ulAf?El_bP@y&1?{;O_&Y3T+al*uYErqT&ri$Eype`w#ezGb!a^7Pv0ZKu+nqt;`*eH*YY(ti8D4|i6n7$t|Mrexjr2&qmRGtH^^iACqy z<(wZs9!uUly^@~eRM0iK`3*QI1hS5<0{5oK0H0t-$J@*J6MKFGt9q$?kiKbo}InDRB_iCY!zmz+SLj;EG#f~wT&Ge9ZJmp+uzBU3-j`R z{m0`zul_sOSNS$V@qi^;pF}|S)+fRBJNgC&qxTR`dbeWk8*Oe6YFR)hJpoX9NS;3O zD#@vSK9pDlRxylGs?vTi#|d32A|}R36WkAt2iA0N$`@x9CZn&XH}B0v_-o3~1+tyo zfrVwbH>K{l(bB8Jkv`Z(gTbf@dq7>5J$&w)?sP=+ zz!sqaw(Sw@(=Ks>e;+k>>R}rDl(V$~yA4Rg40se^Yypf)jZUG_x58TWqF?;??Hi|( zP|C<(_Bb3XwE~uE1b`P{Ui~r5phx16wXxBLT{yqrG{p&4s$FO4ub*D~u=v!Dnbvqo zn;RJHb?}rpPE?m>XTPN6Bc}HE_5J<~_RB0U-%9_$lpyZPYV~(sZRDrZ>V!D#!8A0w zb2Jjoz2~DB7Hlk8Spjvz5Z0k4#>Odro9zt3Hin?!KlTpDdTd*~t$c z`<4!ed)~(#6Vp5-2|p)u*><0Z3m z^~h@3`g7Z5Rd!YLEriY{ti-Ins(zctfBfpLYO~kOb(Op^g-$k8wtgC}dm^R4hafwx z#WpN+L{rid*KG6M!E4vOBxw{o0a3UoHIm);kpt1+bu@I`yuy^I^FZ5Eh zl-~HruI=!nnT9LIE|}|Hv|;JBnD_zJlN)g;tJIJyHPM$cg*VjshUf1aX|S^;M|r3Y zjXLTB(wgo-`SWXQ4?z}qltII0bfR-D*Nu?N2EqaQk*+E& zp5F|7QSbs4$rU)hx(Pk3^-(QE?PF(9j-r46+A>k0>8&P_lU0;;CirrQ!97t+M}>@+ z+OnOxU83`YUHhs!Pw)4iq2-BEb}Z!YNj6UVxM!~vSvlmeMT#ws_9)pWL@d*cdWIuY z0&uss3;7Fz;_}9{$qkh4l7d5HRXW$A3c8{savU?R-AFXNGfux4t^%sFxZrDMl397X zKc^mrVHpo--&LuUHzhXvY;|2;uU{7@P|#inzj@oiW@>3k+Z}b?X63Q4cO@5J><>#? z-sjPWraf~0{Hm08T8R+2GPE2rtOAQXty+K`iri4E7w4Vy;y7*<5tHdsh~>aItz_pW(Qnw}bo2!xE5e{^wzo zai)_mTLVlv!(X3=YXjW`rln@Qx@@}0{OKc?&}>O;2YZfNLvll-W;}0cMyWZ@IN7=! zg_ex`l$Ybn)$5-P?*eZtsIz zTDBcB+-Gbnss`ah_N#(eaXvF8xNV8G2|oyu7qlHzQAbSCX=qcAv*PFx(pgC8SmvOi zT@CjYyy=|VGphR-eG^`zj}8MZ3S!6Zw%?V9D0!OM4isr#=9LMMENYE1Q-a(_Xm{IeUZ6dzPo+v7F$1=psE5C^&UWI3^ zZrBEOM#tFsQiu|kRaG>TFo`n8U2z_H&zoTP#bZ|B!cpMHXcVM4vd7a{XN^@@eZ#bT z$yo|#tRp*q?M7J5%E)rou4he-u|d7L2}CttalrFCyck%BiX923>tFd|$0&sZg6mhV z2!rAoEbK3}d`{o7*VAZ$=bC8^Ro^w)o^w`#Q+C%cCHZRB2N`hnbLDsy$3sJs-Qg!d zjV=A`tI(0o@pd$KJfDyT|E1FovsQ!$npaV`x}UQgDxi_DIkfh-Hg!aMkKv02b0<#CSe;y- zurv7)Dm5{)>%Y;)ReEUA6jWNOTCqjMa6;iosKLZ8gAFPf(!(u9ys0B=RM&UUA^@VP zLoVYwHtaY&Yf|C6l^sRKybwFYzQOkyx09F}{iZo*MPCXpDFx2vN)||1eUOCuIXSr= z+Aw=_*WqUi%erDfViWp7g8@1KJC*W0XTpA=xRkPwdoR(KQV=Mk(h(<*oD${p>q+0Mzs&CORJS5Y#^I5iKED=?E{AN-iC21F0V}+lT;poX}Y+__AUZDBR2iX%>J;)@{D9eiYqpXHXzj+sbHI zV20mvJ^mQ9I=Vgz$9qbMqoesmI+-?anA)~z-F&BW^2RW|@2O)^P)Kqo5C}lJVhBW; zK=^KW%xnzE!B!3*pBFIwHJ3v@&Se9cmLE)NP~VO9RY^3bEVkK=_*C1qK4wLQ&(O_5efW+;}8b&bUYzX#9Yizy>y}ZRv zb4z6JE_T(8tg1G!1T2U>x;SA>OD0g-CfMo9%yt?wHwhjDaG)Ll9E!_z<{_Ji<~I6Qqt0*B4xM&M2@y{ z#F{*OH|D5&gI2bJ=~b#{ck5AAKt^kT#2Nj}-ZoLtB=o2yc0x*Qd{qv#$R{@2@kF8C z8SwhSKaVS6gE^yTWNZR1n)|5E_)au9KQ^Npq|&L) zfBttTHgrnEd65bF+Z!>bGc^oN_{ep4|J5tq0r>4L=#gp|L6W)3m1^C?cNcLNS;>>~ zTYt*F#lTJSA(QlC1PZl;^Y3Q#;p5kS!Uwwq>b+I#{-L0ugU@Ho+`V@>(ixa8$)|^Z zvZv?jYPo|-VN5^_jqT(|P7Dt#K722QT1_P`n`O5T;F+p)Y*L{mbsV$K7>+8eFN$y& zRM+NME=nANxz;BVg6h}B>Lk?IlUhBa356iZ6-dJ`hXA1;F+_}*oi+_-b(hi26Fl}Z z%*O!pqJ#7EH&x19YwdL}IB~b!=V+X|@(3n6%1du0h#`V34P=+esWD5cX!-Ujk46!%<$654balJ9V z)?`-&w<4uJ-bC%KbRL*!i|8LE7kC0tEC6XGHsv+@j?O%V~_qZ7DOw`)+89s2Um6tN# z6kGH7fZK5P8|X0?(!K)f`)2{_dY=EQaz4;EEovQCRd56&xYivqJ1c=N~XzY=Vf7@eFX zjF^Dyw+GY!;^D=CoPe5|@Lum)@OTuYZtlaBkokYL03}Wl>m{hwb&2xgv`MGqRSc92 zDdBVD*^KXGs648oxA>q=4EB;K`fIO^Dd7&R6S=aH8un^yfgkJxwO{2wvcL_^YWWt( z*>mT7es#HUISXd@0eyJa$=~&XwN+9#c(8@}B@EtMy#EGDAmlh&n#UGG8$iWdRmY~F z0D(bc+1 zHtX{oJ*~Ct1~wesqTzXvDq9OeDAVCco{7>%HVnxmmsUKct1j&Qjlr1AXR^oh_!f+- zs5gY;$)cTmJ*v_qj5+d0I}pdVqpgb@zgiYUoOP`N(S;W!cwh zE^WnJ0A}6>JJsbv<=nyH;bA%ac<=FY>haf{9Dv}x8ri5&?Y2t+=OnqVJK1mfvjYS++hA?ldN9lc}E=qKHJ3fVS7ORj+LBSEmX>EqQWFJ)Q-L} zVfdHN=m`E)u=K7Xn>;Aq_(tpy+v%%fLrbF&cklT7noZs*eQ7h}LHluT5Tt`$-NYQl z`>vL}bXmNp)%^Qqnz7b{#4-sRzidM_n#SL6C01AR2?ogQVN8_A&oQr0cp<|~C8&K) zpRWG=EaOQ4*ob$%4KySzAM1Vhj+-rx6s@f8?hwz~C^*N*1=#6BAGIapZHqgxM+qYh z#WKE6Ozvz`ncMHk*?}V`w)CW4LavC)XRMlu;hElpS{pM1OV2Q{EBdMG9iA*Bd*S-50^HBk;_!i>8UAdbgN>+A@ zwQFjD>Au7QGndO$Y3Wp8%SPIzIX0ukW zl($VN9d)w%XA!^xjL^z4I(?=0Pb@DvQoOf~QGa<|ig&ty4C&+sBzN0b_$+4w{>6VmIJE{KstV!xKIurW z9%hNfo?>>F9EH)oHl35yIaf4*6kD?V6g15ko%EoPJJCtznPeI#yGuArdM1 zs8|?!*S*G_2#1EIZnCtyF^gqL=biPVyWnZh8ks)80|2Zv(&AkHKEcVm@o1>dJ!KCaEHpRa0uc4VWRQ|w@3wF`%hSjjPnDBdkT3Pm01 z5VQGewy4&QqHF3iPeO$1tEZkspWcc!ye;0sR*0c@OI20%5(ULs-^&15URdb3)>PLv z$lr6zRHml)=F4*_>B*}{v3ECr!AgY}sPLLgP0t~n?#``ZYb4~x5d@@9?T8b6pfUG{nf4C)!y|rD>^Y&c zbak(VGzYZ^kl*sI;C5Bg(`s^8n7xfUeuFITlir5Ksr)o7G$F$ui4nW8$7JmfuDVl) ze@{MMCCVQWXNu}%_N7U%4&4pAW<>|PhP@=>v|mDF%T{Mnh)Po#t7m0$9u12y?OGh5 z`;$poSTnem(Ot^;HXOYlbnO*!)531_f)L46E-VBo%hTf3DXYnJf}Ahl`*(n*k$x`e z7CrsF`}gnPyLa)Bx0;a_{6psMo|Z;{n={l^ywgjH-^rT~ag0ZaIlj!EJ$t;6iZHX_ zJp0Q(V)*z_ZVQU!TeeOE^1>K@g0IgK@he&k-yd%i+uiXp5UM!@wHv7THerAQ=&4y|5tN&&X-mA+9i1{h^u< z3LWlGfGwxPEU7o7eZ&q*h;XSoskqElIHDW#+h68D`gJRNoxyK{6~3IG5D%$W72vab zjzAuRkL0D^#Q!*v6Cd>-N|QcBS0rBDCx!0Pd7sBIf;K~(q=t8yd9;WldS-(kwcn@j zPMgM}4>XWAxl3PC;=ZW7gcf>A5mP8%QK$~@-#WG+Hr{Brc~5W_?pp=#8_4X34Utr* z0e}R-W>@k*c(M9@(TCu?-0(0jq zNvr_tF#Pu3=<#Pr{UZTB?@PpFkc8}~KZ#7^Jj)+C)hawcNBe7%?wO0SSQdel@L}2k zWPJq)V*lnQNvz;alawuJNo3d4+sVI@q-uL%Tel(#Ilj?}eVc?~aTCF)X=C_i`EiQa z#4hp%^7C^GKh}!lAF$a}xUZMxJaEOR6H#x#rwhhE}~QAHNs zIm$RW({vskZpF6Np;+QxfliQQaNlNLhf z909} z(}$DCxOf%!deHjSFqqp(eCQSoodX&`Rp3Y@vaU}07aIu(yQFr8o^GJkmV+r_P>-oo zprY7AA;%P3;P3-znQPc^LY;sJ~l*; z&`fqRw_iRUL5fA%jC&hcOq+^-a4)urMH7=JN|r6pO41Z7kAiirbQ_#g8MZ5hZpfIy5yNFEiYH*eHbKfiGeziTAO($XdnB>yucBk6K z^Z2iJ>1l98ju>;3`YA+atcProIj2tPT-#+ZU{)zZVaiAJd81smFI}hrP~t?)spdQ5 z%|77ijNSvdSNU>>=c&%5Bw;H|mdDaPb?&W?gm0Hwb^Fa`a)~kz~|&h2}&S z^U}9!8V7ngjb8ztzVa zH$YCweWE1>f0&0x!>s%s32MRb&N{*^Z+}krQ_J5iD|>(+JDR%1vGbuwDDVAq!;$(G z&47U(Lzi(_DR7^|k_hpKlqJ&pGJu}I>|_sGZA_Pyj-IVs*CTh(2k;f?ncB?x^RQ8o zbTm_}^4eWOn#Ta9UQ=qSEtVprOO&=)ESf@NSV-7DhwH0Ac%0!~%abh+T}8bid(li8 zMwC~Z$a-Xq45~gjDdI(}<{{dmFNV&2_sk^&)V!A0=p?vmC41Sk|b!uh9T9ucsSPaqaG&8p$ZqgKE>bGRX+~du>u+gJ7Pdzo1CXbmH102wn z!QG|q>*Z=-v1ot;-47}Ll%U*o?MCO&_*4hl)SzGlxw_ks=5*^GrGVoWrl$7n`oqvC zA-tl4vdfc4QANhv&uA@rZiJ38P;@nw#GMCCBw~ONED*7J=;XY(H`&Er>2T-6pPfsI z)54z?KEMioev;X5cwbapT>NpMje^aM!?2#4b?#4a)ykqUqP?8yOAd^Kjj59@pBQa! z;tyBLB6|Fk!`u8k+QkP`B~G8$_hn^Dm9&au9o>7(%U4Ga86{m(xr(YkA%~vMLj3u| zdZhRb9XJZVM10NhDC0`CY55$htoxX{G-aPbCHFycU&EDU>GZSM0e@2jWKL+owo?>= zl@lnBL2;EASCIJ|l*@x)Df|bQIk**hy545zSVu%@h|K23Wzdi-vfrk*VvCGXH`Wd; zM(EWhdZd}vhT8QO`>9-1*2kM1H>;=EcW{_bYbiGqs#2r(Q*K)*_j^M6e?krzA)D&t zO8mZ$9?+CW+N#cb_zt&DAWSBXEkLU%=>%NnyWPD*FaaEl>wbhz{|adbdyDzE0RAC# z$BmX}^9JTQPtHp*{vmk`6h`gR1g^#LU`SETcC-k>>C|8ky zB0J3`*qB~kq^>rZ4;%r+hePuGpuP1Z+p~5!lXdK3H^D;Gpn}1`S88%Mb?%sj#N>WB zgUx~&WLg8*gy4#QeknvQtM2u6>P3(cA7QdM(y;G zqRbD#n#&x!X-Z{Gbon%yY`skuBxX9}DpR<)_MEFRJ;)Z=;*%HF?JqZjJo!7nU z*5^^(VNS$PC1@pNL`>iQm=Z57?12*wXue;CT_t6_oW><{oKaBIY~s45$lP=y!uPlT zgkcZUE?v50SvnTvI}Q}Y0J}f_`_mu5)zj};3w(ZYz|hcaTluN_z8R6J9W8V;G%mwZ zFji;zyH8Hx4;~{cTizXv5f7==oZ##RDPrkb++4h*b#1z#0+fa{p z&pe|2ZO9j#UfwUp5@y#KD*e4f(C<#WK=9bzBanD8;h zPbBso>5*vFtP*5hRY!tQ<|{OXyHa;!Nn&X+h_7N)c6RbNxsIu*H5ySQa$QHh%CAL_ zcUO$~G%+QUt+B5QzspgDtGR7mudq0-hh#S1`qK3Aj9vF9o*jKT$BcK!3ngT_ckY@k zLI^*4j2&9Etm96i?7vk9R^bjkp`~HXmi=}f=bAgV>VOgnna#GlRTFNVQCvEjsp&40 zlR1{PMASJae?b^`YGUx750o&KU7VI03(u*(UvWt5JVQy`0o%2zNzf-LVWPrd#+Ud4 z$R)>cmBhrXRW~tlZjE92smrN?L4vQAEp;OD11hA7yYB_vy z-@Iz?rIgk8`Iv7bl}}`Tcbf8bJh4eU2=f`0bIk1tcbV%z+`TPGoAXv*F3X?@@?m`o z>Sy6owcwBd!a-nro#|4k5l{Cwoi{kIfF?`aM(AEv`G~=WP3nv@ety}!9y)fwqj7Kz zw&e)@Wg@{pCNKj;KM1gWE6<~)(tq=a1m(b@&RhPn!V*Rm{wGS(HnLQc1AuUHC{;9PI z$v$pYJ*?){1I34BQ0!!^na8v|$6U2Gc)ZnVn3ag7n~=hE4~se%5o$!IKzs%C4@GC0 zCvs#G-9?W(ovWCMWmVRn6)4`@U~1qwei6{0{x%Di#BSK|UAZm|3nM(`JOkFn4VnaM{gkWS@60}9Rhi8Hn$d_W;)-H-A#qSYwP96CA*t% zps!nO=#wRox)_Vk6jdt@ki!OvQMyi7b-ajMwM!R-U2g_n2Bh=19)^DKP4C&0#Pa_A zuB~rgDPpcOO^Ef{RiKP5P73P$==*`F_bpJtjSfa+qJO&Lh04YK>vYB2>qlYok)$pk z;gBBfdcxk3Mb&v-(SuJOh)Sl|5Oz{KSe4W_KC$R6jUnBMmtcYu_;{FxX{GGONWw-m zz%u>j?A{PNO5UEgG?E$oh4bstN4T-NzT&v&ge1FkevIPmc|m(+Dht`QsuQVlbhnRQgXaf(r`4-&u>&V zcf>%FOAlJSUpfj?sq08;SW*Rd8(@=O%Eyqp91cb-b|X>dc*WBB*XEj)MkZmwsf2I1 zXN#J*`)NV$G+-U?R45L%d&5j_n}Nhufue3+?49g}oO&}sqUMPcD(mtGz8yp|4v8>2 z+?&e~&am0lnF@s2*_w;^Q8?yU`z_^Yc~=p|xN7<6lEdBbu$+3v*h9i{lvJG=%?G#N z+99J}P{IoVV2p~EQ^Qq&i7?H+I#s?!+!V`kM^(e?4=eR-|H#m)>=>TsMrgAl;A#sutIPN*7|J z+(D!qVNi|$lv8>P>lrj#mWCLpUR12RUKQUCohuW|#eid{JiL|OTV%KlAr|;_yiFPn zvix}yRQ<-FS%c0Po(QL&M-`m@9f&{_#YlACMFBZe~f7@s{tX}pWYl_z>LucYcU zWb5d9whh3!OaXU)c`AhKFpy~Z?gb3%t4B}sInadq@PPN6@m5@>@qz2~dAk^YwIMTY zD5b}0znI(8=sS|Vhyy6L?)sxtf2K>mYJiN6gFl%x|0HaRV}_9h$+ylpJrF`qZJR(k z90er;n4hb5$X7|*cYrU?q>t3}GfsI2pJv!!{>!izy~CB8_WUt>XJHh@Q2pv&J$8dA z8w%-e?|XnxEUw>0^}>#~V7s=k-9^~(B9+W`-`aS2W~@#@W7Mar0$JK^$bs+=J;?me zpHwz{!{K9ezJiMj*NYFLU*2fKU)nn0#BMP1@%1ne*4>hZSo8combPU zWM_2>EQQvxB2by1H=e@uwR_G*Ub`Bs01$>iE#zpSk^9cDL2sO3W=009PxFUs^dMV4 zh|YR{4l@}hENAnH6tr1@?~QS9kMRr#Cc9hi@>)hkiM~!Sg?Yr5SvNW&pGtBhe0)lS z*xzGD{q#a({C=)d6Nt<$3cWcE?_ruoqeIT--nsZA^`bjZf;OqiNri);1ug@@`N+(5b zZ^sn&c$1%`;?F<*2OGhhw6w-O!fiHEwhL@h^p%oD_e9|L;1xgoAjP8sS0yHBK`UWQ zw|=k8R`R|S{`uwV(`V04>NkOCpc|-O?axru)6)aWCDWg_1g z9N<>C`5JYyMY2Db4}x$H6tkIk&1A5~dv3o3zi!L!GOta{ON?pV{_HI#otE6X($mL6@Cm zIE`WxWchF~Hx~R^dCXzVhVFAO-%aLT$-(4HJJ~m}XHbxWsnQYfVERc^BzQ|?lIHr8X|IT#T=s*x&NUshHIl%cCqM1#ja{0z!4o3 z;o+;pEND+9^AV9$QCbd|Av7s3-0R!*yN;!1#}ZOo`F>*<$4f2VAlYo}2lz2wdWl~M z;VZS>w9Z0nq(01v&A9cR@Y?eL_hGHV8?sF6+?qZe`o6%dKqgfvGM_#G0Ng{8zr=#| zr{E`<FlQ%bY*z*=PO;N1(DX%jQ^4(D}iPJMGm-?@r#M~-OrwIelC(t zTPKp*S&55idQD=xN5vUp)_rdolJ+CV1WWxNNWlT%?@sB;j z;ZP3j*iF-rX>P6&~x$XRvCs1lY3)FBa~|UZ;*OylK(B{P@C?$4}&+ zG$$b(96JggU)NC=mtMQ``FebNX9b~|#8=Rv!#4iM3=8UGQ`aXw)P}apESm(&B9Bgb zdo|PK)os6c!nb6oXQn^v~`rJb@k$Pwnd)6{U3==#XqS(}N2*!7A`MFbVuO)?Y5 zSU;3LZ`VeHJHK^op{ocKD8G0E8rrEvRn=#UsfxQDgOw@m27A&FvnWpWgRDBAy76U- zwGPh7g`c-CxjX{&5qZm6H2Uj)oSBQp6khwDy ze-#;w4I+1uD&!T>SvX%Ia=(<=xevilqaIhR2yf5~GT* zlxa8x$ook45N&(xS6vw{Rvs!YbA#t6>GIXDF7a@2Zxju`C6Yrrr9Dvs5C@=&d2o8;RuQ zXN)<-0J`BiEjeFAI*pF3$Tw=Mj@)xTo?rL9xk>YCTLL5OFS|7+NKlO#sclZzxl@-$ z+M?d=gu-RsvGtg1Tm&$JRd<#-? z)Jxezpl?0S*Ij@!!W6O?r9XB{mcKHom2g0&wy1)Yxn>IegdFcN=MXI+lJ}^`J8@tn z*SeebK!Yzf$|+A`*ym$%(x*o)ac|zd0X9?vRY`iNisNdyfEteTbb~W)bJ%%Mr=gs^}6f6&fJmp^?*7SqB$z6WzB5IS} z8rVE*QwgJe6) ziQ{}vaKCd0XdCJO7_wy#$t6{i{29|%qYOgLvw`TJjG=L^qu0EXb?U_B{enBA$}M zM0no6^CFv+gLEwK+PNxqK67Qu@4Vvi8Ot1PGS%td6wXTbj~_5gUwKI8d)CZzmm7N| zl9R`V%yAZXpqsomO+qU~3JHw2kH$bVb&|hZXeub6|(tpO-J%5 z-mm=$bmrT+qmh$$&;HiMH5x`Kv+ZKhzTK}Plrmv6qcaV8x3yN7?X7p=Ay60&5X|u|{w};6tMG#P3I2Qsca1NceTnT= z3BiL~#Zravt>SQ(SGQ&&yHS&e-}yn%f-K`VtDzg9wK*fp%|m9rwYOY3n+_@ai&vt* z%VNP7*BBggN`Tc@YPt3P2aT}P!SB?I0(qW@vl>AskZEB%-L+iETLEf2!){BkvI1ox z?Q=^qi6dUG*KdhFsUz8Iejiv=>{#R_G5Hqj=OlGw>36(OJy&W{*lexGoA@%vF@yZ6 z5WiT6NV3iwa~)S~G1dAxA6gN2ps*=J8XL+0y)c?0m*%vy=zMp#95r~;^fi?FtD$uV z_ZETr>Lqjy=uINm-R6f?+gJ+cH(d=+)-E2jidl7iVz>Mt5wE3#GSA!yc)|*dR-o|I zQO9NG14U?(@LeQOi8(d(%wM!<=MAWnzVy+^Vp!0*Y95p!`R4X(DmOs6&Upd<6Vk6) ziH13fjmI`U@EMJZ>pA`CjQDMQ!!=y2m#s_e?L=^HcW>{yKBt!}c>A-8MyE-gFy_EX=<%MltObyd_BvxEdkD(CIb zi7z?SqouKwz4|>K;YJbp6;*bUlil_xM7&tt?u7*q*cLBV7Cdreyr=>(@HX zFx=%F5v(lB@=(oWxZTBdpU%bktJFiKD9Y0Ot z?dp~CHsqYGIA^lR>q`2@ghOS&49_8Z4yV(a*!Q2=Pn3#Yr)+!;7#05Tu*WYOfk0GM z#kR5ZEe_IR3lEhO8q?Cs|MJ=fLvjYl!9y=@PBzY3QtDkKelX93@Qp<@(1nkjru zh;}g~_`(A^ZJ7wwIvh`XwYAh4gsHG=KpJO}Uqw9YG<8JD)vjMEoFc9f;&bM!0qt4j zC$AL=d}1aeKfzy1l#U9CEiAjdyc~f@Y-7RfKttrNJ~f$E$?;P)XZCh3;!0+j{uR^W znPxJlPVA_9AT((aAwgPbQbI|jh7v$RC?V~>alY^Vym#Fn_s3nA_XlBRC3$o9dG>Sm ze)c{mzQVK5EUe#*&pb|eqi$?sZY#N>dKWXWDv}lg`#Zx<*Q(|8tDi4i=^|w=WQCv` zPd)yku^w)7hS|xvpOEgqtz}-_F4ur9vcR2NTU!eXUH~KaXfaZGKM&G#Ee>#MGb;T1 z!3p>;0?b;TfV}P_=A=sm{)wo!!ZxmscFxYOl*!4zD`%y!6V^>5hlj#c$p3ISRUcib=>>c=VK1`mMvI78QGybIXpHOPq%Ud0_h@u%jy*QeRu^a_uXZ zq4+jXYyQczKjfbD)t z`rZa#DDv_1S%EBtl&gD%DKa^Hmq`#__lHw!A2>h)odW+$&-JHxcp{&+qKk^6?tp-u zmsP-mc&mK+eHJSe-r2)f87Q-e&h0}$aLDd*TPwQQiI`-rL)i5`>%B3(?BXY zr^;RV719-NgCwnGOwZhK9DKc-%}3(rxX`1_e;YxoFQ?o>e2?GR*|~J&Lf(g_8-m78 zt~A*Cbcn>(PkokOItMm*yHEW*9-w|jbraem;(4*%>L+t3y-la{HyjT{^QULmo=U7- zx(x~)5oEr-!^csb|NGfWH?1Pr+MWi$HoUEn?EMRQT^r$;funp1LPwO8tWBPSJ(yH_ zeJ=gTIkzu#6zJy7gwv2u%q>#+K{F^L&^zSk1($Dn4fpGFNy*gbm$?2#x;@zOU!5;*~4bN2NDb}BEgYw;3Hb3!sUjD+3#~}gB7}#t6lb$Fjd<4MuOD8rod`}EM z9yr0p$_^*?Wu18RYs6r<#I3(B9y@3L@@Dm)38J^_FWaThivwE>rA()iQysc!F2VDG zV|3a?(l}wFI`vD9lUBNweD@jYxWW_76F2_)`S_U&pr0=M>S|ux5P(~V(TqVLbzilr zQXmBKxSZ=LUm@2kX_WL{bn?LofoRKOtPcs;d{<&HZg?ak#(sbP$Vzu#3mlE(Cgy5zkP=LQZdbqN>+Our~ zDL!Z5N1jmXIx^ViR8F-e#a0ipI`ZXYfzU|{R*evHCX`n$zq|g+Tgjq9;fl&eGFkEr zMMfXQuLrVtkme9IzHgwMja}zJoV4C%%lXa~R4P?PnDpSNkhr+DMs3A|byLt|h6{cC zwoJUabx3`%(SYofq)J$+Y?DylMTzGYqltf_(w{k#$)*2jU|-2EmQ__AF+oz{r?4c(|UTttx|{GN`lCPoM2}hGf29 zl^&i*m{EppJwD}dbhLGNDAnR`jW}z~x`w7X&E^5koP4~UZMO#_BvVLP>h8Vj0_B@o zM`}k@6n=~%hO<(R80V6VGZdIpd0=2?D;R78t@ZBW7KjGjUkeZx@!r!s59Xd9)d>^F zojBr>?`as=?|1Nkz6s4UG>h3HmC072)&#g*ItR%-)u?yxx^`o>hCZDgC7=Guy&$yU z`ngw(%OFs;ASXaJfdDs%7lg2YTLG8I_EV{K8Ml1Ah?AAZb0?LA+$Y;Ix91BUlOF^w z1`ZsXEixK#PN*(JH@!3CEqOOeNKE;>GB3arMo9TJ2=q<+G!P3s3Ijrb{i&`aFZmu;SeK37ht^h1C@XiC?rKADg{fVWt%kUS zLS=esDHAuLC5wO$Y4qytmG?NhYwlWJos0;&W5gS!cyCl&_iUCAJ za)fy12P~~0k--d{DM+GM-#1X=`BO@4Kv-dGmSmBBvwrF7&9tS@gJj*Zz_&B`{=Wr6 zg7-g~pIs`H{!oH$N|g+`>w5YR&aC~%qFURe0kE)e*r-(eyQ^}mgIFhG-(OaBL8F?P zNxH-_UiWhb!UXVrSEG=CmuX`4oL4wUR8;VcFX*g_MsD7F4$n8$3b zJZ(JWL}xOvM)UXqyM~l^X*}B24t_Z&oy^$1V65CO?i8)XT(q=sKQ`dohksZ#84VZR zl|2^R)kFVoqP$hsA1~7bm3AcrNB z64OE^@qHzQOCe;($!fE25OpoV^2|?HjfVYsqk*=a9Pg$_>5dZ_rkiDzEgQd2jE0ZF zg>$Nrj)7)p%|vexKtoi%zp$bqvH6=?xq18L#N>mr#|ljyR_+`(mY zpcMC1_XiZ#JB1)^`*XnQ2o@!#5u{WAe{#lY`t;qYB%FGqZlYx^CC9N*Hb+~SYpEXY z{dxe(kL9(tA(R*8&zGl_PKdsJA8b$Sf-ozs_U``skuQL2eWhVt9aqDiBr|tZ*eaw+2qBftX@g9)vxy3 z%_Zn4>+O+y6fWc-O0{bnbhO-rbsRP#t`?SBzjgM_ZeWNOI6wN${ghPz_u?(hf0Pq* z-~P01sC3#e`He0to#gj7@B^3=tN@oh8G!Du0OSsE&OIcOJ!%815YN0H>}#9d@UCfY z_`%2Rvd2y>+@2T3ps#kJ4`xdJRfV|DwLKYFofzfkN{^l>Ulzs&6_LbG@;iG^iiWq{ zcL)uIWuqCs&_BTN{*(RrtByOfF(cpGck@(--&lAoeE8HWb~*m&w!Z2E1KSGb3atd# z{Q_*7d1A(a&&qq?z=4tCyDwfqAV(*DeO`|a@0};-4_4RK{4|mhGqts{{dzcGXCr_?{eZ?=gqyexdijF^I}B^Hoe-vrHov9e^T{$IB2W6Ha1x1Id|Zt4~K zD78);Ew*!QnfB;WL0m{UekA+)Gkx}>Ouu^u^53VKD_@V-0cRjs{`*y~VjF>z3a)#O zwNO%@foj9i>sbPD2TO<0g61BFy_k2Sj&+Jk)*B4N%n1vJy5Ip|P&lPnxd*3fDti

CNLufl*b6as(quMR#({!th>U$wrya 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

-TomzRSx^7SvxIa3gowg7sp6@lN~IAxi>xJ@Vrk*1oew>8OpvF zD8Ej53#dc6q%)`~3VUvz!!c2pfagxo>>_0EE2d3)^Gc~Vo-q#}yvakiF~?!Hno}v@P(Na~b29@Tkon z*1X+jm$OmKoDJRu;$wafOK;QGqZIQJ?$LSoxc52o&2vyr$Vcw~$>XBPe5Cg*k`*V+ z--^Mw7NsshF}iStPM`oUjo~1`>|Qq{oAO9hJ<#|5O|2+nbmVnweU3~vYc6x%=;rLO z)Whuq06h(#lBK6G9F*c18W!0JI z?RFHDmET>fvg90}C!W5f`!&H z6^R5nOAg1DxmAk0ZGA`*AKW16l{}?ba1Y`Qitux)xB&l!W{+P(q}aMs+-G(cD1h{( zUq|>1sjbL_!%Hr)Brh=L!|UE7Fj?(`s`YN;Qnh4S)ZvE02TAe9594-Q;`XLGdnTyG z(ju5Vg(r=7W+Y%&a2r*W^b}9;lf$pU1QSHBet!5c?(-+H{3GnOiwNd}`i2VzhoTc%Ht#}#~BRX z^HtDIw|RGq<|4l9(HfWvd%-SeG1}o{UaMMLVNa0)e30MYegG9ZbiB% ziT)(t?g+I_f;~8GmWgl2Wml+CXL0!3UQ|m#$2l!d8ULD)-m-bFKsGq>CN6h&1o;dS z7-c77RxiRxjrbDFuOIOPmi3D8v_QO^2|3p{1p@J56E-id|Jl4SkQiaxFlj&HLfJp({dYAS zD4Yh$ATDJ^+n=2lYB3FMzH^YaxY~cyD^@gU?yAc`r& zybgxNldH8JXA1ZFGJl{|4mMlA9!RpyHQ%RG)M*xCuDf~Rl}^f2pM(QIHKt{K+7Pe% zk(((JQQQ`LY2A39W>dH!13syMqqw*Bd74r`&64Mn!Y5iGpRAw{aAl%+? zNzLbkLync%GkMEM1#ku(5VtPCD$+NV+Pv^penItd`Cpm|-FeI}>FElL!(-ifF7QqBAOXbcC$pY~O@#zr;VfYgV6FEqDOtw+F zw_z>k+_Q*L(vRZCnP93*&H*ZF$T)HTEUFJ3SU12-+gUqe2ln^&)Th(m+8dEEEjyu$ zp6AEu`8_EqxzjOwwf15rdnc^}^VC)>=F-+qs(O{E zNaJHkupb>NAS&u|;n|xg6iP<|rVe)c?SloOpmNVC`Og36#>b)ksjBTfgKq*RH>KsC z^&(emI3Gf_w8wn=#47`fZGNDOkf(<9r<7wfPsnK>%1D4M9YXg@^^>pj7}X8mUU%qx zHAd|ipDe|dT>8CwQD7Z3Y2k(zW9s|h{8|VHJt}XP3w7i3(d~xm>Ya#PgI`eHYsJEl z(T%l>)9|8*Xm`Z@{((`b?v&)l*^f1DdA*VOfk6c$Uj_=l*}fpkpCKG}f-{kmY4JTY zp1Q72=zD(Q;?cX1$(K)KXklQnlufU&uFS-m!FK8tMMB+miS(&Vp$|f-! z9y4>Ygf4b6?~B|Wd?)I_Rei9)=M+7QnUfSbtXl8IEYGy%7UHq`ciK5KH;yd;4AGI@ zAmYiL1_B~Cb>CN=hh_v?uo1#p3LV8t)5J=%+$hWgiRX<8`^H3yqT-rQVc9}#sR}9% z4w^lbdWc2~#}n7!6QvG$`Z{9FgwRv?MOimhey|t(0X;O|8bop9G~LsaP1{befX!Jj zVGJ-?T3YHVr1qst`Y{wFgT$A#eM=VS(1MG8-vq_)|E6mo0)XmTBB8T3+L$aM8c1H5 zvGg5Qo9@Mry7=jE&-vU))6z^g@DzD@Y2czl={*Us`(`aI^s3^;DlEjyf*>xhiE-3T0z#lQ9Sxoc=m zFp1wgO0OO|-Q_ICRvt1Cj1R7oxmZ;|;CCMqCyXldbLEM1dx{?RuU(Ms#`_n1{2Q4& zcx4RYHq5$C=$w4d?YqG%QBlqC-f6d`^uxp@k;@^tr5RX^{eo|J6&ICS{NL7*zW$QZ zuNoLO8VVbz4+UTno%3WeX3EB^^iCS6B_nh6b;2Wi|>U$%;JS1-iThzX={Dzjmp;yR4u`zPO1D1buy ztg+NgvvwVo%@~q+_@KK ze={)m)RXM})}p$1i}ZB9M_})YS*^HgpWw{LjyKI`=ZqgLb6!MTQBu2v2ag_$>i;Qa z9Df>QM(rRPS!-e?1prr!I=%x~TjbF{;w z#^ELTUBvW!4er#UuN*U<@sT(obA@ex>`-{FeQbf7`om$f(Ex-L8DlsdoF%tVji|Og zarE`s=S7DyR(7HXC9t-*=0O%?0kyhSF805cEJOGt^B(l{l(*0j-;H}WRY0)EVmZkD z7iY4``}cO1mj;pL>9HxQI)MI8s}u!p{TS}>AijHB6~{BNG>o{gzb-bVnI|;Ud*U0& zGdCf{M^EoJ)ScAz^-NUQDCux#Db9kKIY&|yUTBKda?3LyvUjxxx}S#mS!=4^iO`H( ztF|IeSdBH0TgQW``V%?jZo|$2d`tvmW^sKg1cuNZYI6B;l8FKP=&1i$*WSg=j?cz^ zi@(Z7F@Qubesxz+dk~=;F%}8{`#)>^zOX}T;)FwbQVm{K{v9$RA($1vrKrB*bqZ3r-g{&;@sS-{0W=e z!%NNJ8Vn=K#Zh$Z6>n+xxjGK=2P@kONZI`0+Ay6c*vP9gp^ya5)xb{9}3L1 z#9n8GR2W(oc%aFAoVkdaOqbl)ZA={!@yd2Xubqp;O4fJa>vnX#Q-_plE*GDhff#a) zZxUY!2_1~j@5y@i%(27?9tT9-5;n$XHHknRcW*h65_Zb$qx2afMbVX9!`cSHpm1PLTtLB*t`M`wKneO~&%%TTBo+|daQ^%Fw&lA<vqkd;kW4~OEFBTkQUZ-HSdsHHOViNs1TsNpddm}<(Jc9n#*bL{rDQ;Yu+dXp1x@R2JAD;K!rs24nVjXYx2*?FOOyEVL+QAd?!^fWauJykJFH$!e~*ch2kVGW3dT1=vY=Er|` zkk)7Pr4Q^>>XTp}JM~0GJb^Jh77A{6qp(ZoOn`s@0c3VPpecZ-l<7x$ig5IReK#ei z8I2ipJZjNzA1jUx8hifJ%;Xd|b(=lU>;^jW|L?^I$>%GWF& zaVB4ESWyd$tVP)vF$$nn9wh=gXaN(tg>A@yqj-w@RL0J`#7*?x*!0W2-mr=0cZ{Vy z_)0CH3C}ty^Zg)+YDqTa(~3mhQ+KQ-)`y&LR{32$HSRiqDL*PjT&y7T?2AzdZ{$uZ zzJL~CpF!8^5Xx_t%%VRF^BsMdsg)%*<_oE7GD*6?tTEiQ^ZUGq%c!?e4xij;006SxK%ujS65-|pOOzRc3R~xJ8^g>K$lL_Vo^$a=eUgG z@;_ICZ5jyjYR!c~d6N4+nKBRzHht_#he_V8Yb&HS`K%M<_L*>8z-sK(u0yXA-0*>J zLkDI-Q14u%0Li2K62Iq9>Br{t5b$+6&C+$09y}8bpD`Ln+4gF)V3dFNYZyaKDomiP zo07pyvRCUz;CX8hrS6c3%X+5Ulm;+V|0MRZDO-rWhsPjlP`G^M%hu{66yQuNLj?ZT zMi#sI4ofzINaByh!)IO(qwEM7J#-+jSnp8 zEjJM#ozS6*L*B>)lkU0X?i&=A-Vjqu6aMg;_nKn(Jr%)_JbK;w0lZ8{#-VRSD-`W+ zhhI_dx=t9>$PZaa^|O147Th^1b4NFK0OMnJa>luW(vLIb!anXv1$utn-+q^(G1a1F zEhwtT?#zg8yt=x&%Bdo;OCW)4WTHlO;=O#>_hH_$)&h17SuOl~6f!#?cs5)<4CQaqxEl5#h zfCcwXa{uTy{58h7!iO@nF@=W2XvJ#>+K4}F-PJoE`AXi-_NDKF#uw=XBo+)hMrQHy z9J;A~ghpkezR#bMMAbS#{7#`2y~$4^h`wlY!Vj@+8huK2P$aU+5O1YgBu(Ez$1 zQp)u1d>$(aT8)bb;`}oAXLU}&%{;s(eM$ZQl43JZMaXu~3)@V)!WFFUJ$j`q(Nr&G zDer%k>8WW?Ha-=f)om^R)Vwy1@DEOb;HtA5ENm>04U0gjZ@+kFjU>f@IBQ9`(MRq+ zF6e*TF$QZmv8OR7i@Js*_3$})`urnrXTU}hJB7VvP=;os$ItBNdug0ocIo$?fXSoF zVp}zikx1r@xh24*4jV=jQoSRDmg)EdP+C|lvhXUa ztQpL!2Rt0n5A}!5J*pm2_>s~3qsYzTZ{-WC{~Vdb`(mAD`XVwM=gVVq&TAgtyZT&m z;2Q3}yH(3K+_)%3@K>x7nzu7-1mH*)Ojm)Kk;NWQquuEfPZW+`nH-!XLpgoy!eOqv zSlRgCZCwriODRU$lK@lD^|MMOLCoYUvMx8Ch@yHKs@wINPW~t5tq#W#%(~aZ`}Ozo zo1{2zf8|%Y{C+ndcuO6AsJCu7_2&!KvbwJgL;QzSWYx%7QX{5AOSS{19zwRqb-{XdQAj9+3VinZfaelVZCpouM*IAwj2x(jc-N4d2h*Q zYKx>{EXi`%?bdct0^p^5?Slg`D}u89D2mnyj=S!?xZRee$hYC2*9cEV4!S5#%*|sF z9Z&9E-Zr4pd(vD`@qKKNiIB*-L;x&Bj_s(i%&qa0ujGEZ?r$B)6y9}B)8*37ruz@Z zth|uDqk#^F#~grHx$cZTxkfyA=0Chf0T9AGP2#S_W!^2_9+&>CZRF;SZa3K;>VroH zKztLAk3(@CkC)#VpG4PCfn8_ea`=o%S$8KLcB#g*)5KL_rx_Lb$n4;sg1^9jBRInm zy5BIU(iFva4u=31U3td_L6wOp$DAK_*9##(m8P`?4)fz;Mc1me2-s4?B)k8zKHmyQ;E^ZCL9#_T5b1qO=<46-tdPDrAz7HQWUpAiIu6 z5a@i3Wpo@-xMNjVFz#iTmYJSB-Aq>>-q`}_9L9FPgG#l=lx$}+$WHvu^z7;bjT3~DgXWbL{nv)=KF=kZ=Nf^l- z-03)9Z;f>?NeP4$w*-p8mE(x7Iv)8twPZDNvJve7yuqQzd^rMLXyR^IS6mvKlMjVL zD=lOfZ^zTjICDVdSE?RSQI`se}(xs$_#*3>V0@0s++9$z)P&0nRxOocZp8x3i z<09Q9+w{!9|CRT@cou;|-0Lf#CyhKOaU!WbXiElM=$B+IwW$1(AYkof=&cwi-X}>T zheCv*S*(ibxTY~%kdZdRexm2*sryH~Jg}x*KN(f~*s}9?wtNBTuio zf4Rbw^@y&x)AxhRs6sDM$3Im|@bgS}y;|oOdpUVTnk0sKS$(z~%K)|0;(gccv8GQU zj$%30EH-IJ>=@tEB1JOsEyh|{1A};V6*m)sX!9&#EoA8!h&v!S_d#a;70q5<(m`)+ zFb(9t6*#84=^!nKQtlKJa-R}>;ydhPQTqLFx_;@1Coia%Rfdh288M~W#VtYps4|mujvO z#G(LpROTx912g;TfY;0hmKILlwX(yt|;xjkgtx!S_%|Mfj zFDHsP{{F<<)P7H_;g>WBJL>iqm+lLekyWfGX6|3s?iJzD&Kv+elN%DbGg0c@r`{fy zE7@;*+L;z?D9l8-b zr_Pa{oez7$EIa!8YeTQc8b3<-?aIF(CNmO7f%Xz1j&xNef?tEk%v>N_mG~_e;X=kx zcX$|IcFK{wy@$rm2*CJS(1dT91#CLk)E$1bz_7ke>t1>fpR z;DDd8cA0a*`r1b1iVewxN1#%9q<=PugF~XlY@L|U*;sk=)6L3XjPEIR zavvBw)3hyEE^LJlM8$rtjMkE z;DKw?3KV>u$%T{=7R6bEErCybo4>=F@6T4~*z$Kw-fSwdPzV(=SFAu7=wq<~Qdl`W zuEk=FJVvvN@$8D^>oe%DG7M{BfG523FOrxk0V04N5xhe;em+6H<4eX_`n!6y82(Xt zR4w=mY8dmUnYJGvqr(E**>JBvd@nCM;%Fn$Gn2>ayz>pV;pU@V_u|fo4q6H;`EZ~# zee@;i=AYq*Y@Bt}aSw!MgtOxJ^4If!iWv$6*_6M&L0vs`r+pjCdDU#Cx_aDa9oxCO zho0aRP1%SW`kSOEae_z)*D2}BV&2JgM`a*-P|LU0n$=KPT z(x}K3)p6X5P&}^BX;VaPzs|xH{vC_#rNQLi$VY-rxm9=g&IH(tCM9UUm2HWaQ|M!t zn7v6_Ar06V#p?o%cgO3Dj>9N&f+U9SiT)_)*6Ys`vsZRUzHNMdZqm5@^a^$mkLA?t zaNCcZ8SYNs0cGmo2&6N&OBik4C3uV>xJGe(4UB1#EmG!aN{C9IaMV%&+N02A9md1wK+q5B#KH zMv$c;TtaOBit2q&d1==_Glywl!8FF(QTA#B$WIIUx;JLWg5K!$rDLd;C*!F;RFBOyt4A| z$#NWa_YNFpKT7t?8@X*64;LU7v(aDqFg>7G@-{km)b+CA?h9UjIhbTnQs<-D`Ejd!3@NcC$%9fyoF9azpUZ$NgPD1^xxJ@B|LgaO-kLp@-W`zeL( zNG#E*9gR9Xbe*aq5(E<=_!cghOC&WDx&EqND0c5TvcrWoou|Vl-A~CzGqmRgRRCFx zl2r2Z(pV#G_)FLbHCBQ*E_l={#3@0lYNOurP9I6j&q1DYUbfr=GnfG4)^K{>#CNNp zs9Br~{~J0iAcEF&CP^Y;c5cpB5K05gdoCb5V<=ueqN>yna2h7>`ZQgz65sdgWvQw+ zr+?Ch3DBDZBnZ<{JLLj4{!+@~P)ws~dcif$H&q)nbQX+w{W+mtcSDvxFm241V^L`R z#tsY>N;GtKtq61QJN*JS%xG66bcIFo?JE9^>#Ez5VH~~#G*q%$-m6TsIdKqqoG*St z`mpDZ;W0$?oRYN}Q+jTm^sT)YfW>Xqz~Cns9=1AWq!uV^Ya zAS{Vq5YKdo7~`s~c6=SpO8^@W*x*O22%cz_rS+RQkXL9i z6M^et!b?3jEJBOlN!9K~S%1FxwgGLGi!=b?LXFg6y$G^CPS$NAe`8pvW=(Nv%R4G6 z^~3J|ks$aANpVIJbOh2}B%2MhsXQ<}5W*tblwxj~0pkha)>%9}JOb1ie2A>Ny84D> z62f0{?gH()fUnT)=o`|DPGE1%z>$XN675U_CAtb9f+eFpC(xbx*cAiL-6ET)s|a_u zXH1tucSaoehi{rDYgv9Dn3;6nNzDpit6@WPR+?Ve(|At_^^?gEsGg<03yZ|(LYmK) ztqu45=cUmLy%Wt+(D%|55*z84sJy8!a<7&OfV6y88dlV%0Fa&$ zSnzq<-h>%xFlX5AyZsFRXyLI=xgRlwPKdJ21=+}S;qf@zbMGEWlCnA;1n5z}VU6I> zhPxqaY0N-2b221pp@^icr1(rMtAnCw=?_&&tPzwXEod_vm%Y|Nrj|-x5y?A$LmSoJ z%l!KLVl|gGWsB$t*$n-%7~iLNHctX!p#u}@&#`oR91)>c3Bb3{%@wN^MJXvMq=>IV z2YG0HeO2eKrhR!)*iTt z757a1k9w?;MAvN~^yK(hoe$252ACswd?j^}z_+mWjdr;oCQL5MvH+)#Z-aMLZRVbE zF06y06*E@~UAi4v5@j|Ibs!Xto}UDC**v_zgR)-3C?RS`>@29A27;Gw8UEcnBH2s| zaLjoIlJe*~a@fn#s(d1r7>Zj;pf{M0d9MSLn_@U<%kAY@;Psp{dwdl-p$S~Uk=%*Z1uS;Vwf$Zeu>bXDn+kM4f;*oeCdpm z1saj%#YatCSUQO?rG^IbtwfcQ+_67g&k0Gyrf3E?@9P7@)$r+LlB;JFMIp{4P!T$> zut4%}fka>1*;B4--XvR;**(Z=-n}z%Q(oU+-C%HksS5_t;1hXAmw7tjICCNb~xmI^KV%A};u>?T!F>0HGNw_l|s7d34I6(1DpK=lOYF@;05gUBGBPvA6(DGn+lDa=(GFgF@}00{D_!i=us28jFo;Q0+3_Xjk# zpFi4D7|g|92=0}^>G;@=r0=W_zu`T0FXencepE!fQ@tBrU&#LXpPDwe>t}N~rJPJK z7R3*Rf|0+?kz$!2g1?@%A+9J}G+)%P;8iZe31Ps3IYHTNvuhYiUkY;LP;I-xc0g*t7(YaGsxnu_ZwQB$W3D_ZVZig i$fl|JR8ipY0fdg(`~)7|r%p6;F}T2oyC3!Mxd4h{}WNl{iC4h~=d`<;b~4Eyw05mbhK zA%9R&kcE4N^MHf1q+C>lQP5lz4Zpy8Ed6uAOS5Eq!-y#EN@{W_zmSMAaEWuAjH}_` zXyBA&rF6YlPjY?yO+H`0Zteg4k*n14naP&*G2vKcu80QJsSSFgBZYg5wT_F23u1a_ zyCUnd)eArmbRFUvrz=N|p_xtXW)n@VRTX>B`}j+pmcust+is?k?`ExjPk(yT z?WVQx)V}bP&rdwe0dCBXAq2>iYoJYdhj%{{W8UK^ql7=oKO~m^G+~NqLlzqEvrZ3w zo|Ss(6$BjNOl3j1|LHV<_|1|e0`^9G`_DgaOag;{{{8Azk6 zcR~h)_unp{&;Plj<^P8OnE00o?*F#Y|FZdiqxG3dj5+F4|_+~z!b1K+r zDAueTlP3Ej>N|)pxU#dOL)zZ;KRN^n6lRKgal(Pw^7cO$v8)m;7qgXl!rw#{*$`Wh zarqW~ileH9s6o=r9xp*%Cvbc_G*j9`=&cx~!YZ3nQ>G9V<@%%iIEv0+kO#cXzjhtHZn*0s;aG!e7}>RIWydOL-OXGYI}_vOV=S zGev>qW>$XNIVklQi%W zYVi{-7q+(EHbclk^s=8G5OH+}43XcyO`ni#yWRBTNZR<9|Mooy0#2$d9S_zrUrbRe zTrD%UriKROCNWqPc%8};^Wt&VeY+a}rmw`N_-;~W1$ z7__ST;^t-#vU4VABZM6k!BJwGkMu30?H?S9<4AFBFMa2Ax^7}`|7Qf$3n$5ZVneI{Rj5Ys%H3{H@gnC2y+X-%sf8Mh)&p|6-OFCj~7_upeC3}eiazol?_6YN&M~4 zm6}<=2V$}iaovNbR)kZ|aS$Xt&^bWpkaI`MtXq4%3@$zQ&Xnzo`=r!mrE3+k`~Ew; zPLzEb6F57cS5y0d_(1Le{u}dj+SrZsjL{4?#mXccG6Bb-YW{Tuw ze%;rB!eO@(Ud3A`TE<>}lYzDyOgiv}ytiXHHJA=p@70T_uIQl=-y7Nc2mOUNo576r z25Du9wt{C1m_n2C){i*7t`cP}R8Vj29q?HRmwHI$IuW4D|Hg>1pq!01G_!|$R7@&| z??eh3cyZOEt82grYQ9%Y6=_t{+NFf+;Htx^Ge+~2&Lq19Jpjqm%U`&wsOh4^^+-z* z!-zi7V7jSP2Gk*XzYp`%F*0J3suj3H#q{{0h_?B&{20{%=LdK->u^Bu&EAm)byDq% zSnN4JzW9-XxVYdj^SiyD>rKjo?7{kQqWVEC?EQMt%JYZ}T3nnTxrUcr`@-k=@n032&Hgq(c;bIa!Nd~FJ_M2cQRTrxww9+SN7ZE9-!paaEir( zL>K;^GS7aw896el{Hmr zUnmC}-@a)}+J9f13OzySBK5|qqpts6mt3F7yl~WJj`f7nX1Z+!62(QHJ-O!qKgZ^B z*bqj?@JG9`9I%7*ET)czt3kxwVa3htmL54=NT=|pvTfBnK8ZfcjB_n+DVsc)S*-}& z#U1q&8g$d4vr^kV$>Bkq1ac-i%|x7wY5(yaJOtE}tZ)LFhp$8T;r(!}acn3)DyZt_ zeh+}?EHobq%D!^SJm~T>7G{32v*uFh=00wmlK8|+nlBXm$PYs)+c~vtS)?}XJKa)k zL(eJkTZcFk_~K7jUvjisCjudIml9`mRx3;^}%*SbV3aQrX=Wcdx6$FtRS#vf_5s);lYI1?+#nyN@CIu3_&##&@7KFM~CG|oty*j?bGLNXjbof`C2MhG?V zj`HYvDKv)K!NegG0$yNp7h(LGN4Ey*K@uX%Q1SOosYvGgNaWg4@&~s&Ax2F5qJlr~ zmuF3aUC2MUh(F$HA9cJ9NvU+LCz#b!;s) zNcZhUUKnI$q5WZha{7M0QIbu|p*w?DFF&0nugDK;)AGRUplrk3MZO?8U+Z92+diLd zH!S+9TI~y#K&6m6=13LE8bE*`Kt6jvkTWD}OqmO_J`^mkgn-NBt}ViG5S(eT`4kr{ za$XI$nTwu){VZ2p2RtAkGoZ4mPN}Ft70C?84f8w#d{^_G;d(qa^3h~OKE;q|prtWR_F>-< z;2s!e472pFXjQ9dRod1m<>RVA;csnh6B4R%b5(Tb&QOJrm#+->7kQNpRF-={afa`@ zUP)@In_mS52WCO9^P~U_CGzP4L_Jdt#TKsLhPkN^adm$L`H&TxyVUaGe>2Os_;iaIXSvFKT0GHMf&MPEH9g2zzEN)?C@1-1r6!iqt6&ZeOWN2cY~&imKsA5+@y0@dz)xa(e15mnv**(4 z0~F_uNnBx+?WFHf&co5VWJtzXPO(jpny@^q?B7f*kvlM2DmVqn)xiJO0KkjlHwK>- znUHbXZu*qd>+6#!(%2 zED>|bbgwBYKmA9}$FZWjl(WZ!C1aZ?bbT9qi2@B!eF5V{!YzSrU+Rk6e=9hXy(ihd z0R*a0DDWkJA@N<{aknMeZ5bw!aZTHJvxfrl0&z$|ls#%*3hBBeF^8>cm_VNgId>Rb zxgRYd9|xCCBRh`?{*Vk@apTead8^lu@cvx>?pB~A>aUO^o>}A4B->?z=_P}>=~m4# zM@ko(7*xFcCmDK{WGY%mzWc@W2fr_&MfnJ;m7?D`!o@-63i!EHp1U=wt^4+R=uF3> zOmcf9@iMqpj#2^BfF~SqXC&oH?CeQ+magnHMuRry9&T#B?#R%m?P^Hk`zanj_C{UbP9bLMcT(JtfNr%(LhA}9^1)284sTDd`@IjC)f+n7O~Jv7WBCPJZ1 ztkGU!Y?4Tx8F8z@LcP$OBRb)q&qDW3coDh6IBBf~4+z7rOjg}G$eaZD-yi#8CTn^d zYy5hm@6dlSF>)GpdDr9bXDok$Il$RE3pTS8AIybMf&pt;ZDcJ8xS>aVmVS*IIVxukb}1|EX6M0)g+Ext^Pign zE2lY_MK!4;!*;%G(z3uN6|oR0-VQ(Y@bB^_n(QdLO`L~#j=mKhWydR?(piJ!9|4Qb z6Sb@BXp%W^xymxV+AHVA)^+Ca$15Ih>NIMmYCb185Kd8|4@*dt5`OyrVaHC06{?%` zvyT3c>_M2+PlYRYXby*o3072c~y_^q7FM*sra>yVoYG2 zxwVnCVUS*EQMO+}h2ozw=2PtZR(A1%%ym;UNyYi6EVVkq1>Sbj08Y889w_Z5HRbmB z*QDrgrHr=K0&Jw!*ea?N$Qt)(SyR<_gnWKi0XkM_EIRWapSj^ZHzgAO+V<@Q+a0^^ zmRs>C6B1AUM3qOvwMMK#AdT7QNyo0icDx^ig_l-PV*}Ezct>RoLLq_UA54NCUu)Ux z=1r$%*s2zz#N?{w#pEDT#4^SS%9GLSV$;-ESdN5{ zkD&C>C3pGhe0|5Pz}=C?5@X_97ZKHMkP=Z=;x;Z(>9gU9M4+ENEPAo)veV^|z5fQ+ znQB~aVKSuN(u&0dAGuv&PU>Z|{y}AcrZoj}OY*vAk}~9y4%>qnYw{l4$zd`UuaG&(7JFT%Z+fcS$^du>1aNoeyN*HPqI$S|=(^y9NoQJtr^g-cNs#pAVdBUt(#LW=+%;x&SF<_uz zB2WqnZnIOVE{D6-eMhuJ_ckrn^U<0}-n|93Y^r?Sb4y_{MyN&DRP(<&@ulxZa8ar+ zJ`@l1n>y0MsFJ}EPvyo8pt%&Q-92)Y`=kkE4o9(9*U98MPPc9)eU1{|dzZe1duRe} zQ6tcwWYrSS6(X;#k9Mq)wYHh&HxJ31l&CqAY?W&w3<$n67+>bU(cF8gE508%yg8lR zgkp2pT$IaF)2ox1a$O<3BIUtR!Rx_wFUWa{xH|TUgNyv_j}^bHPph;PxA{W52h)S> zW`3ftV_#y0_eK#D%{r1#p$l9e?hU(2bUEm$p-jN+&b%{Yk^a!Pcv5}bejennt?W-jOd zkzejOB3JFiq;|me{r#OL_pck(I=lJU?s6(*|JAs^R>^ScY&)hnPahlirC3)(?}iH~ zRUC)S2)voWBXIf-H|E;Wo+v%CC+r^yLA?ab$vs5g^5s_#ec*J5GJ)VC{?fi|peerw zjry$cLc|b>m+~3(=ip|gk%^cgnn|!rMLs%)+v0Cxe#O>(6hbEsOjH0n?{zFU)~IZVKGOSF@~#1dWg&$?rUKj0JKH1ThQbAqGN%{001~mEez)&-8H^dNRQLPUiM!ygz9&txJC}bQ%|9Z*n z5q8t~b>tW>^NUt>aE+qa-<>AHS+lu?YTUkmnp-iZ(_8N}Xd%59 zWES>Vxe1`C?rSnvneLR*$|!0gazSqX+j5N{skhq+NrCwhx)-qv5~PTE@Oh+1KQ<0n ziNS8X=TdfGFnAdcY%P;I1u1va6F2?Rvccz{?b0i{5uc2bW5w>gV%%9yrkuKPLYQfK z`mV&z{p;!dD(TE+?inszzvy7~$Pr3VVEd%Q<0NeQEu_vWsuy|Q$2mz0k?s|$PCqv+ zCE9IahEulx5xY87{7idJ`?hw&if4XiUGccv?PTv2Lzm8ANibhOjn_w!(OME$anrvzp`l#!qjRuT0;6h=z#@+Ag7ne{f(J=F?Rh+1 z&=JwIQ^rUHP{(^sd0gZLY3_z*DT;Nl!n}bOAD`>Y7Q($ju(X#r({Dm0M{dapNJtK9 zSN`3R6UXX3)d!6&(#(R#(&d%7 z;gRv$${Ft_ai`e30KVN6M>`9H&VUR#6QTI zGp1SUMP2;kfA>uxsh{(02F*zYs;;qbJ48ZV!d?#=UFP z@L3;P;7$hOYt#VLE1mL&vjc)Z#(t0QBKDKASXd6dR;5gwD(@J0COfyov;A{)AO2(TwJx z54{CA6LUR8QtzsKw4sTK{=K6%`0iuPNC3VWj!?9syoYL8vLGP8I@x-gGpx19*Q0|& zlHsGf7tfqoIg4-lu=K6AU5fng4OZ4Oo-A`r(MGC+N7u7%Z74B-`B5sc)Zb^XO-vR5 z^A`ifdf;;qtAzGEvLNwp9lAXhK(n@A>Tc~@9aYX zm4$_t@fo;yu6A#&OWE0Vw7vG+Vj%cX2Pp8Lol9lqpF&vtKAvQnb_B2^j@Cp#Z8#r7 zyHnJm@eWb^qt&{uyRfU(l~m-`Y{96+pOc(gfx`Zowp%mSb5Upt>!rq{qxeS&r9yIvo%Rhi-`D;YMw!7q4fXxHK@Rvc9rEoOqiT3qzB&e71DL`|{_CqghX$vR4UzMP z$fr*CF!N4~VYv^^t9y`6x!oj|7|vsu!Y^w&PgiYfIC~vScgC;=6)>8iQ-Gk$wUNcm z(lvBcH|BHu5hJT^fXX95JPY-3!103Q#Z_Zql6if*R9CSvR3DGgu;QFmLH>hj=lKO) z`!JTqw*{pgZ=R*rt~@x2H;lX65%-nDC$#v%uQB~_7c=u3_B8HWp-h4YLkoY);N1}G z8h?5Dq2DVS)9d&a)oB6sl7V#{+r^J(ueB2}r3oFDDN= zYe#HB`c7R{GK4DauXRFOsQyBCu)c`XFY929$Fa-5oIe@Jr^bHaRU-GpqUEaIyBIm*@XS5#57w}&0C*sTRJ7X`Pkbfw0RjMldnOoNk3w&O)9qh=TrjmuPQEjLrZCSohEDk1Ueq{73JDoo-3~`p zKSfn2G437uBDf$b#_%#0{55k%`DVe*RRyYuWh3U{)nHoVdWETI|r`anT*DD3%F6c^bR;@0Xg+}=}*QP1j#s^tJ8wN zNS%2Q)uBOA#2Hv5Wpl)4KjYo77Cq$kA<aKOGWC4IP^<*UNou4TSPD8T+~W=wqAMUKpe`h*SNlc{7%go3 z>%`{rnN6#`)2u<9Idl*2BEElQ_)!aeB%E@kxoNILnhg;je6+eJ>BlhfmGJXp1?`#! zR%Rz~-485nK1NT?bjZ%cs)wJ-uGAmHt(zMpJ1OEf!%9$&%Ru9|sRF}~H~ zdMl{#`kT}o7J6Ou{aqv%J8YA2$QSjt&#}MPm~EG|Tb}5=d8$(UdZ+hw!h16E{3_(f z6V<-denpFzrS(mq?oZ~Pb68Zan`&J-`oFaR=%2`UKF*|9S;_4I+p2}z$b`6vc2VrQ zy~{>#i5PE*QuTJSvWCxx{0%v2o%RpV!_Iv7tH70UYj~G&k!JEaCjnynC!(^S;RiTR zs{iznm3v0~@fR!}e@7q8Jnr7xuRbSz<9KTQvuc5&%Oh`~_0R8k(VAT~*i}xcf0=dC z;j_2sW7KLi)H4}y%o`BJ)Emd#aWLPm(JN~b&v=+1P1@ReO4!D}kVm})eXtvk6r~JY z(v=sg(d*L3MfuQKgd;SCgZ|vii+^~NM(a}l5ocVYh~Av;Je6t3ow0@mf%y9x`qV-d z&MG<{og^bRtINuhN@`K-E<*<3XnCl%vT{)E)A%1Y}eI%SH8%0F`UD?iJ=Y3G% zuwUEeY9Z}`{4_i0qJ`3!okHZvZMp+c*~?aE!Yu6W(cQ*yel|mxPJ!@7^o_x*=clW0 zP|oF6OH&X~L^8HOL1+_kox$uX(TE&9{#RY#&a5DOE$O{!36YKG9p|mzL9P7;%FFSo z6O_CyzTRKv{^^h_z8+r*AyULYF%ar^NMl~6ULL_|g0$}V^@#B}x^os70NK0TOEXHu zN*+HabZHD#=oT*#Zk7P;t%sIw<*{LVS;!Futj{#&F(lZ2kzxp`JUc?ZOvpEsN``H}wa zcqWou+t&+?!Yd=G#ycu?d>jdcY!d+KdJtyIL{!a1!Pp)cm$?ox)`X>mpBzYTop z+`}HCLQWGq=a7*Clk?2WmsF45agXnu8w0+k3VfMgkiWV;Z1L~5Ug=%xBUwrCHml*N z`=^iq#|G&uom*X_hMtE{jyax2ZVry}@x4ph_tM~E*(f?fN?*-Sh4# z-<>W`O4fX1APa4xMXQG4hJUVIIHHaO)y5jBw=f7{?;z+>~Z8Bvce zIRm?^;-NP3XC|LD@0*1<{Ny&C7`o5vpM$6X9t}{i9g>$qKE1E5OZsvH%AL-$qeeS| zm+kvm7Qc8Jx5k7#bLlpt5RuBO0`3vo-SYRmZWFxk zc^kgpk-RSSr@%tuyjx<8q$80VJ5a6Kn869iYQ2Ui+@s#Bj{@Eb+L4#EV~fQ3Midp0 z&Xt%z6EGDI!Ka(anzt%;f8j4m+MqmL&8JJg^V%RqcWS@D7PJQIFO*#`}@CfG=?U>Tgk!%7*Lz<@tU z#dtfau7JlujG$WU?Ode2hiZ7XkV^qSZ%auv?(;pBP*UG&az~9MU;>@Ui z`0&1yCi6&s8L3h$F9+_1cF?go^uZzQZ<(~eSO%mViE81lRe;C9*35U~_PsrGF?Mr! z<;VKKqvcR`*FzM2sl`TJMuJLpBKh(z?gYBqJ`t)!@wlMNe&UDVW-&ey0Oy@Sps$;JC>X*$fpWk)@M>g|%Vv(IU5=o^V>lBrs z&oEp-ivA_6i|AEqh%A*~z7+tz**R_K<4+nU-c3vLI4C?&D@oZPApig`T)gI1NisKz zNB@e$P5mVUi&u2Ps-yx{3ljoIM|+!sZ(IvuPz<(!9Tr*^hit^}3DMZpG+K zCrl5ZDd1uv2bOJfj?Bc&XR=ojPRg#vCNw6o6bKI_iwx4zMJNA>x}jV(irL>=Y7iZFP==rWEULacE&N z18PY;yvFRS2UvUsydhe-pHDu`Vi3+>6PtN;0evAjwyXt*?l;Mw5Gdo=98q7)gcY?>Z$PM{Tj zQCry@fvET)- zj*}ExORdRWETsusT6Pz?V6*Nm^hzMLZ1CO{-(n;zQCJJ)uop83_>)<+W+X~_9fHmn z85?6h<;YXMhj@yQCSPFR;LU!j{N4x6YpbnYsMEIE-tX;1$_05m&j2Umyf@BM2t<1! z@A|Dt<3ANc_H4yw@(weF5WDGuz%@EN>XmkU5aZ&_e49wiU)|L1DY87-FSuTt ze6jnP7Jgdm;W#Ap^zS_<@hqw1yp5Sfg@kNs@j9s#v6@8Zu5%d8QoT6%C#~-9mGEcGANX2>jxjw=241_SlmMFgx zUC;ddRrg3ZT@~TfZ3C0oAg1#Q*Otb(mJ$~$oU~viw`Uf}HEN#>!1c7qi|bOxuA^wCbyGy6pLOnNWPU`ZKCD&+p4+zl zP^B=&$Z8aArC{qkpNN%4Yc4RS#Xv29x#HDWZ1`JojV=T@vm$^rU0bP;W*8PqA#r)n z0;B?*PcU*YJy|Q%5&a6$t(;wA?cCwSb|SUkF9yS@M2Fva^W_RL?}%H4ALZklrPBE> z&IHgfV)Hbl(sD8S(gvFXU+L+lY8vTPwD*@5;yG+S{+0tL-)2+}QF=Eco4l0@XqMp_ zEe+Ne_1`VdZ1kmvGn`f+*`=aS2>)V9ZWI~fz(hcJ#I{mumQ*S!}yYx2K{_{qN$jcIeV2rlT-}_fB;04{^ z;w~$qbJaPbUh1iZs-NaoalxhSyz12ao5LIQa1r~MOb7@&|8~pc<=HY4U~epSW3h`H zu^PRuS*9x&$xfx-Ok@_<9@B-&eOS6b_-LRs)X z>fdZdLBN+@i`W)Kcys#llqXu-c#t#&R)j9oUZCWb@1ZtI6^zaGfz=65hG|%U^C3l^FE*gi7$<^JCwL zH>r>fr;GxA0qjZ773|*>J9$|tN3B_`6|3BvY^)bt@~@J5XAdZFYRxnwIx?7%y7(|m zD~}x}AIQg96_I-P4k$>xrQ>QRDr3_?(CEnWgLflxsj)})u+h)TBb{;98mQQqp@Pgaj+r<8 zMrL0lZqm@#6GYF(FvD?h-u_tdPp=|wDL9w6Fpo?$Jyt5^?x!OIyt>~}p`dqxsZOMI zqi2E4AKtviw?YYDouSDx_hy>!W$P*eb%g?>m2Z^gP z>GNR$c#h`$>IQa~q57R@>u4d)< z&6!TA_4kT-%L8dmlBz2ol4LNxRM_SJ;Kne_&F>?KI>%ASrh%8x{JMU#cu9_D0A9s< z8b|!5HL^Z3TADi?+pQ&0MeGP{3?BTTAc&L`xAx=rtJdyz@=GY24^%Mnj8`R<3)^GY zPLJG!+vXzsobyH za&?6(xH4>|*2wVjZZrwE~i9<;Z`FP_#HA#U`i325Tb`42(1wOJ(N7!Bp)M-&Ll5mik{kQ|MwD_jHCm*k&-9o>F5&t~Y9_`5ieY)bTA8KSd65Y27#Fb(y> zICdE2WTWOY)j4`&3>V|VW;HC7`$h=i-F=@GU>}x`=Lt+Yt8BeN6?KbLIkq)!l~#)( z{u`j&T>WYJx9=TU3d!~q_L7zamq?8hrIrPUSy$Wg>Z&hmT~IS_ljS@yoma6hHTTrE zPNIDATukmH#&e4kbKXW_fe$*E_?KWg8Pp8=g5?Riq;Ns7XJ)2yzR`!Jx{{tts0ni@ z;=`plqc79IQLba!(BD|k(lXOBB)c`toFS*m2TZ|yUtNQrx9k{dj5MEV&qeqm$eU7d zihPMr*(AH9w*Q7=SeYzy2LW}tCN+;n1?9&X$@X#GrAXVxjDvbdB|)OH3{6^g^W7Bm*bZy}6;Vhp zxiNp;jS1|2SV&(zOX|UU-VsJ#&8PI>-FR`iN1v`>L%Kj_Xw-YAlKzW*o;ma zUa8BCV|#`Gv8gi=f1a~Nw=T|A_bcuxj%3e|ymvM{qbmeg0XqXO6*B%R*o;?8WANx6oc%r7W+V$BuRHw5^X z>o$Q}VuuJl#u)KfITdVMSfLIo%RHVHef+o)A>ZV$g{j+6GxS2wI)A2&BNQKn-c@%l zkGk#%T`+;DaXbXyMdq%!66l3ePDazU-?EsfP>?(9*rm#zQ`CQ7VH5>OvLm-6#89?p z1^AKdmIa$(QtY1BPtp6PZJ9+0djD{Rl?uWtd5^wDw45q#yDf5_?Z#9*e{ra<16s||q|&aqP^?S(tq6rEl_C~yL?<$FglIesYf z*eFLzouVe?vtMHDh^aJZWD|j$LmXyZLr}#BM{Lr_x*Aa^04DUwFAP97YCPo0!@i$o zTIBPE1>xMIrTsLml5ATH*z1z$w+fB+cWKa#Zq^_R>tvXD~B= zd4GP{^~N%Z9@DKNYTj62(mkY@X$x?FUaGKka&eUDvX0$)PAu@w5C(NAgLYsJjpgJ=g7M}-&`7fd z+c_L7TA%!zw+BN2K^S&LwjR`(IOzPBZsyM@LBGC*LQIjE^j&t1~{rpBGgP0&M zqCk@sxt*G=3uk%q;+GfhUAXMS!wz z=0l{_=au`(2LC{#$5@#LI3Y~0LxBAJIxLlCADkpK`9r7R{*D}28IUBmg4a~PigFE| zEqw2lE*t;2sc~O*kxYVg$lJu@^eIdsyK9{vQcM9QW z>aIh%E5#l9+;@6%jYCH9X&}2Qv1)`djQ2rNkL2}p!L=;DVehR;_+Pu@!gNB?xV9e5 zlS)axvQ<+ptL%Jy{s{Jj2)tvv%yzF+RRHH!%lq?J4YOYlreoZf^!Y4VKb zYUEr5c9OL`^Y2{IIV0$WZtlxHiDJ6pAowJiO`~RDr{%uCK@m?CGAylD`St!N4oK$7 z|BAmyIbRkW01%>RL%zCIDxxM1UC(9h<)^W}cACvtqL%jY(V+u-&&9}4VESwRa?aAD zf9%D~Fjr?>RCfG`u#!PR7kgTmWYP)g8)Q_An`tCDP|VqQZun467J8~&Pk$>8Gz)z! z$sx!s4aqre^}6jb`K;qa;akp|1VigL_SU&INJ=y)6o$_;ch-~9!o0^LB-T&1SOKLA z&-*O1=nlrkOE!oJ2tKX-y)i@c9xz8h1CU6MDX+{U)1zJ8935CK6@!q;kbL*vDD2zL z7Ya=xduKS$+WK#-@Fz-Fr&lWzJfP32A`HQGq2D!yv95xcla`7W-=e5rk8zQwm^PRj z0x=1!;r#H-7X$^>B@g@szX}J<$NY|i#|n+e2lafrYb(hcjE$}^5`3x9&_SEMP0w`0 zK1WzjBO}*3!;2)OV5(nwWK9k7J9keuk-%gD9Dbn20w(te)HSk+;`$97SyHfGjExaR z7vRP>b#likNp*-%SxmbZN45sv=^2k3eGbKhb3CMZz2)J9nj41qKM&D(p}U^u+AjVe zFTwQRnsN!x5%vW29v1jnu#D!hj=n(h7Jm$r;Y%r!s6Lxdhei>QG9PU>2j^Bw5=5`T zR?*O>sL!2m)k1kHY*2maWP3Dnf}1{YG?*#VY+cW#y^`V(Bb}q{9FOVQ*+0ud&InJE z_%J@^^|BEVQZe^zWVMzFF5*GuK!Q*vyObR(-g8`+uhhHgwP(bzgIp<7-c4RO%F?~6 zCi#mzB8ch%cY3>&L{9{NKV-7N6Qi%L&0$Ka5VX5L~0t6~|oPr!|6FZD2jGZIFzQhvj|nw?DpY-uEUj^>4xsrB#d}ZcHG;iVY^e zQKkTy5>y_Ww(_`y1fZ1nfwCI3>g1a1{X+}XcnKPg-Ir)+c`i2=k}R0${wuQI>Zy{i zgXu;PV!0DvAMS8my0-FOQz%3`BrO0F8CQ2wfsEtK!Dr5e7*8}VRHMW&R)K1m-hP&v zNsUOq39RF{@R3eSqXM}%@`9|s5OnnX@R^QuAv@1i62Ung20WPmw7P1SqG>D@g2@b84gg7cDs5vRWF84om2e))Nx zd(!J!*^4EC!SJ__VyhUIibwciAEP(AQ#ObKnTwqDXV|r+dN_CTdHcXI)Vo2nMa~FL zYXHx29E7vms2(GG(Blr>Dy1FWg}~0;ATzV~Bip$?^qEE@GmShpUb7HoSA`5?alZvx zW`Ld(HM-Ataz6KKSq8p3}>1rej|w zW_yquOtJSP@7BfJcLBkDB*1cr}lN!;g`t#!Whi-(=8bSUg>deTA7hnIj88{Iv( zVs$||cS@=)34OXW8hnV`jN0!^v*{U>GkP80y|Ch-6i#-d-&r{1J_PE77u@gHbG zlVQsyKWHLY17U>k!aiOCU=lGpCC;W%x;nBLm<2l^OC!)L3V7jLUU8GBiwP{K9~DV> zc$-R&*V(`&*Jc!Et>bu))(h4BIF!ekn=LNMHnz8b^dj?~ohwzSik#3pwXNWqOHnrQ z`~I!!-IMlVfhORT&69tl_$Z)T1SJ8h*ZGjAni_ms(DaJ=XLLKh)TH9d6q+{q7eh{T(!ROP?XF^AFj6Q zrf)5{+I}5H9D)Tolf7~-uel1%Q`%R%So2ecAPgmBI)e9{*?2XeHc%``&EPg8FZRhJ z;yE$Y3QCPS^_a!wB>gsy_od!{hQ&cAEIUJ5-~Pv{bXT`#+=#*{1+WLJD`C0VgJD`T zlDsHN=m}~B^@0SXF?7f8$2qSyWxWgWbUF*ydR=qY>N=7(g56%atCH2u ziR=!cv=9?sX)m{JAzZe^$G#)HU%lg$b0yMtus@PwoXVP*&<0hy7MhyTXsfl-%_b#6X;!AiC667j#D%El$BB>f;2-Zg$=l=<-Fck%RAKUE%Tl8Wi`)TS0W6!g3&caTNcU27qmJw@RRAP*! zg5fiO(k0y`Akv_8gR}?;2uLgq(w)N6T_UA~l7e)Xu+$PR-5@1McQ@bV z{dxY2=ZD?BU-zCpd+wPzGuN59CQ3tH5f7UR8vp=2WhHqn06@`0UMFLMk?&el*1^aR zEN3NsHvj;gKE6OTsF+E}o0RUa^xWS%S-E?ex>^EWUS8a`j&^S5rp}h!POjEp_r<9I zfB{gJm(ljl+*|PR|2%mnbvWEKY{4Rc6~h#nm?5u`7l{Qaj-F_)H+WY&RaRHlKBx23 zLnier)Q)_9Zm!u~r)AWjOtp^tjH6E5l1ZmnC0SWoeg`EZgRmOmHhho}njRWzfAYur zQ|84^k81iq5w?Vk41tTL3&UVSSwdg*4@bRx$Qv)sHXvewMUkx`@?zT;4#Ao~g zV$F=&`hHm#F7kyM0c;GR$RF^{DFv}SUL;v*L9iH+SNRzvda{_vD_d6dROANq5BacT{a3GEkwLdqiYxgF(<3_(f3B7+ z_(j4Zf#sI~#?{xdN6CR@u{m+@rEU-V9UL44#G-#VPcvb|+5j4JTmYKUa@WwM0BpQh zfBz_Lxu@v5Up}X#SZ-`=ym2#Xh>gT&%8le>m6eak*DCIpN3&Aswb9-Pyy-$*)YjJe z`*ell#Tl>jAT<_wnuKEKJaKz2cGrAE@ktvzx$EfCVzy0g8orj&Cjxgkd ztZ=bc0*D-`K(ps5Uw(f6@*CkcYOJ=0;jJru76$TfXllA@#-kRjox|n)Z#k51=u=3+ zp&WVp;}g*;%i{nD4;QrKm>{nO!QF2Newb{!PG~Yo2kX$0RyHk-X}q^-+mv z^lWFU>?aE3R$bw7w~PUtdINxIcUoi05Pz0QUvBukl!eO->{F~PW+!L zedA_O>=s*nh|Ce}2!DPeF0*x_lN#QWoVmHVw#xyI3i}H&a^#G%`Ad=PDgXpI)Xi{O zA}w-=7ycQAQc7iV9G~gh_XX8m)BhIPDMo1m8O#tUnsLc=1N^5b++dg-Kbq&H<#UW1 z92b5DurVQL!*sH=2-4Bi*BniFkI&a^O|MlX3)YG9cE2-WG z*gZx2zX}|?_M;hSg%bKE+ z^>7gQaKrWgMj5q{>izroj}&MFEgMRm970v)e|D}M4ov=h*Al?S{J5x_QZm7Uf&vkw zX>ahJ2!Uum2-LGU@t^pN+PDukd+gi}y?T_cWR(_VFk8a!K$_vugX#K-z?)j4aY3f$ z_JV?cIAilFk7KgQ^k@gAD}X~0AkTEnw<2j#Xk(#tdZ5*Qz zm|K5$7M($^tF3*@LWr4paeFe%b~~i_IM2P-mbl)lS3N7pr6)WYZI9YSi4FQ&5|jM4 zc%sH_9l?GsM)atjOAapdkyf9RbxQLW&^NKUf;e(6KURf#KBuMS=o=aRf*)9f3e_9Y zvg59ySK|`8YH^8hqaSxnBo6+W+pd2}whs16TpZc<%FgTeG%_Ty!Ln!)dA`}mI*h*Y zDWnm{QD?B&VkLf;wfQyG6*eP(ZzXo=@$vDjeC-5<8yS?(NZ+H1oJfMtYyZ!18PdEO zj4@Vwod4FS#E>?W)@o zhWZk2M>Y-d_9!t@#WpBxO>v3c-OlpsjC%-Nc+||1QH*q;af9EcL(N!sal*T1!O?hn z9=N~Uda3o)fqt6tfFUdoU^}X5D6xcveDvF@g7UP`&2^XMvnk8jILF`ja6SQ&MnK~g zE-}*&L%}}^*vjCSNql18CNLbI>#!XV=emJ%o={+=jv1xZHL zz(m6UC~Tg7OM!yHUpi*gyFK6JBZKs>peTr14+~$J=a`NvU z_vVy4-HY$c0+jyZLF-VLXV4JPTH)5=AZ{!EUm?h*&$9sTP|C^t9OZ(oOG>-mPn2)f zU?PKrWKT>%b4z+-2_s;L1Yd<^E?Y4D4>tIApX%cR!51$oMX|eaBCB!~zZ=MSM?nKA z)Rk+u41fLLJs>LVO}BJT<`32jp0s;LZQ#?Do!MaV2A(4kDqkFWD6!%5a}2YZsZ5dWTc)||hYT|+xaMJ|X8Kzw zTDO#}l31~yUaU_^b>j0@Flq&Y52ZDx+w5MpMSOAJsueUJdfKTENMZ*D6cM9|<96m? zL+EJtmQL%p`c467GMIJ8h@y0xzlP%4w_sJH2}O&gIeT#S8whl46!+#s=`&$`;_yY*(ul(qVYz3q2pW~KpKRTTHpT4!=la6a%qv2gb zz}!{0@aR??_#2rE5Crg@YjFEGWn?I~2wv<@(0$}m(`g>*@>0+xJh41*;lLt18uY=C zPBJE{T=wQ)gxVBt=SSS;VvXsk%PfS(%k93Za2E;aRlo+yH=%4KpKyjQ81GI@O&P1nbAZ(9Gsk~; zAU;pAIraPflQ)PnzIe9V=H0x=I|FL8VFw_#AywU3BP#7S`0BlB9R!PTIEEG&9X;--T z{e3D+96g-8RD)8COJm%U!`o1DSpZgVTAn>jCjnkNL47Ch3_v^09+n4VmD zceeaS73v6Rjp~<#*QqJulliJeXRYm~K9`LEv*=&R#B|o+O)=#k4^KU|nA*p;o`1Ji zSK0g|CbHbhj}pTHU;^$~)?cSrf2+2^W9HxOTU5hYR{F7M?+dhUJ0ow5z<>f-NZY=& zgBmx_Rbb{yj5k$zhNNT_lYfMnFHMG?AqX%xVZD}~ z6d9P#iBn-aBETHLHl7y#Z)H_Y@E2NPOrr9MczAFXIq?J1sl9n&B;;m-6v1mlMaeu@ zR42eaYN-hq^TaG-GA62EML(d3e{NTg-&{pYk&bEI#PW3FQwIbQ+))!nx+1pN@%7vc7?<#AUA*J(sVEk!Pl-j2emK=|o ztZCR~{!&vuZ4l7>l&xa#-(~gRt~+(>N^MWib2p$IoVe=6pQmXIH11a_pn0~BXaMUG z-!?D$N_BL$c<1|b>*`Gi<5WA7lUuJu%t~QO^fFsGd5M@8@OSb7oxVv2?Lx1c-3@{# z=gxaj^G-8Slcm>#LgW1t>e{8u^SBq;`o$jb*LkipdU;I%y z!V(Ca@WAo{hz$7H&(!>V)(qQ;DRIAudF>M+#OqdqbiVMDzBMPja)Zvl9W|LT@a}Lq$AmkELtbc?pM5iwBLR7nJZ0rdsZy>sP zV1>CQHk0@$w#Ssb(duvTYQJTHrN;WGrkk=Egw7^^{iJZa>~%eAZ<@k68^#;d83+6g z%oGiacq`rE{-+lmv)?biZo5u}^xaN2ZS_E&{dpp9->)?D`rCY?3K9ts0EQHC3cd4~ z#+sT()ID4g9kFStu<)OFvfkaQa-{U;k)j?MH;d^1n|j%&kwL*P(lD z`i~+p|9b<9?NR>CLx?o>uls_WTwI#*KH$8f#<_pAgsj0y|11+nwIMGbJv4S8NEAZ> zP6G}g&)p)oxaMYF2ycn9K>_TzcB7uHa*TX8;BCUo$r6Dh_c0_E^&|v~He;)2CHZ%B zg06~$1nc8%noq$#Q|t1(joczhqz|O#HH|3HE8GCVV%76{qEgeXSYC9*65n++2QbsS z^Y_CG<;}jNft3g9-#w%N;o;WP#oMN(wB4I=E)jEtyw#!=hp<4x0Lsn?mV=&V8|w2s za_{#qv0(K2BJIsTBwB6&nMG8^o)n}9p?T+tmb4P zIk7vo+1z1)2POJ4q|S85&N&KvvfXK<^!RXQNbHGNS}e?8CYZUw(v>5{5j|Dw0}atj zgIKAxyDR?5pjYgFyf5+Bg!A?lsp;U1%ECX#JQ^>@`tIT1d-*&SHJi4Y8RWg>%!)`9!B#*Q;^n6tuY~3= z(!}JNyySCQuyJT03D`~7r8iB-uQTh|_Vo-#2r<2h{<%nbZ>1GMj(@@XxLf)=#v!($ z^LVQoDXV`DdbT?YcT~;=zO@ILS_Z85b@T)iq^2EO- zCb2$oqWm=CY;)+`iIyxkQJr3_R4xuDiu3*b;N>4g7K0B54X;%R z+F@^pWQC?;{L9z^y8IW-gqYn@c6y@bA4f}Gdvu9t%SK3{hy4DR*Jcu}bl1GqobeBd zV*KrdF`i5BKL-kI2kb5*rGRleU=d>kvl8?S?Hfs`el_+8_0hki6U@s`Z&pmKItLlo z?vhT!1E@vz%Y?AjzGFZ8xLr*{!ewlV+@OzN!0otK0#}W9Obx?tQ`7U!V zErQu?qUhOIm%&V0VgCcc_P3N#_$r(nB22Z_=S@M9K|DFBX=wZ1n|oy0#<550uqIU4 zk(@-g-@8HV;I~qss?YCGYrLLwoT1{HFKTyHf5L{gXTvkW2_v0q1i^z{L7#nG|KNRH z+~4{B2=L@h&wC@QC_>#}eKi@|HYttg(5P=y2k!~gX4J#Wxisp$@QRYGHKuPdU>mE= zYYHe2gt5tE>6ST>Tbam^BN3)krD)&1^TsLJv`j}Fv8#%)y;73?xw9@}57wO-yUSk{ zC-dgX(JCOQHE;(wPThP?cBL443T34<$7lY#D&t=3u;UF4l3#Tw85TN>D z^&!XWP!#ojpYtafcGfjw4M2k(h7qLs_kx8EX-bB4A_97(H{%-FImy$7pvxtvh)QDE zPo&E~)P)lBu2uhaL3e-5q%2>+awj$|< zi02qn@+poB)x@wofG%#w_8p;XIdCLoQLx4PuXX{mUhE(mQw#3y6@BmjT#U?3D;j!0

-TomzRSx^7SvxIa3gowg7sp6@lN~IAxi>xJ@Vrk*1oew>8OpvF zD8Ej53#dc6q%)`~3VUvz!!c2pfagxo>>_0EE2d3)^Gc~Vo-q#}yvakiF~?!Hno}v@P(Na~b29@Tkon z*1X+jm$OmKoDJRu;$wafOK;QGqZIQJ?$LSoxc52o&2vyr$Vcw~$>XBPe5Cg*k`*V+ z--^Mw7NsshF}iStPM`oUjo~1`>|Qq{oAO9hJ<#|5O|2+nbmVnweU3~vYc6x%=;rLO z)Whuq06h(#lBK6G9F*c18W!0JI z?RFHDmET>fvg90}C!W5f`!&H z6^R5nOAg1DxmAk0ZGA`*AKW16l{}?ba1Y`Qitux)xB&l!W{+P(q}aMs+-G(cD1h{( zUq|>1sjbL_!%Hr)Brh=L!|UE7Fj?(`s`YN;Qnh4S)ZvE02TAe9594-Q;`XLGdnTyG z(ju5Vg(r=7W+Y%&a2r*W^b}9;lf$pU1QSHBet!5c?(-+H{3GnOiwNd}`i2VzhoTc%Ht#}#~BRX z^HtDIw|RGq<|4l9(HfWvd%-SeG1}o{UaMMLVNa0)e30MYegG9ZbiB% ziT)(t?g+I_f;~8GmWgl2Wml+CXL0!3UQ|m#$2l!d8ULD)-m-bFKsGq>CN6h&1o;dS z7-c77RxiRxjrbDFuOIOPmi3D8v_QO^2|3p{1p@J56E-id|Jl4SkQiaxFlj&HLfJp({dYAS zD4Yh$ATDJ^+n=2lYB3FMzH^YaxY~cyD^@gU?yAc`r& zybgxNldH8JXA1ZFGJl{|4mMlA9!RpyHQ%RG)M*xCuDf~Rl}^f2pM(QIHKt{K+7Pe% zk(((JQQQ`LY2A39W>dH!13syMqqw*Bd74r`&64Mn!Y5iGpRAw{aAl%+? zNzLbkLync%GkMEM1#ku(5VtPCD$+NV+Pv^penItd`Cpm|-FeI}>FElL!(-ifF7QqBAOXbcC$pY~O@#zr;VfYgV6FEqDOtw+F zw_z>k+_Q*L(vRZCnP93*&H*ZF$T)HTEUFJ3SU12-+gUqe2ln^&)Th(m+8dEEEjyu$ zp6AEu`8_EqxzjOwwf15rdnc^}^VC)>=F-+qs(O{E zNaJHkupb>NAS&u|;n|xg6iP<|rVe)c?SloOpmNVC`Og36#>b)ksjBTfgKq*RH>KsC z^&(emI3Gf_w8wn=#47`fZGNDOkf(<9r<7wfPsnK>%1D4M9YXg@^^>pj7}X8mUU%qx zHAd|ipDe|dT>8CwQD7Z3Y2k(zW9s|h{8|VHJt}XP3w7i3(d~xm>Ya#PgI`eHYsJEl z(T%l>)9|8*Xm`Z@{((`b?v&)l*^f1DdA*VOfk6c$Uj_=l*}fpkpCKG}f-{kmY4JTY zp1Q72=zD(Q;?cX1$(K)KXklQnlufU&uFS-m!FK8tMMB+miS(&Vp$|f-! z9y4>Ygf4b6?~B|Wd?)I_Rei9)=M+7QnUfSbtXl8IEYGy%7UHq`ciK5KH;yd;4AGI@ zAmYiL1_B~Cb>CN=hh_v?uo1#p3LV8t)5J=%+$hWgiRX<8`^H3yqT-rQVc9}#sR}9% z4w^lbdWc2~#}n7!6QvG$`Z{9FgwRv?MOimhey|t(0X;O|8bop9G~LsaP1{befX!Jj zVGJ-?T3YHVr1qst`Y{wFgT$A#eM=VS(1MG8-vq_)|E6mo0)XmTBB8T3+L$aM8c1H5 zvGg5Qo9@Mry7=jE&-vU))6z^g@DzD@Y2czl={*Us`(`aI^s3^;DlEjyf*>xhiE-3T0z#lQ9Sxoc=m zFp1wgO0OO|-Q_ICRvt1Cj1R7oxmZ;|;CCMqCyXldbLEM1dx{?RuU(Ms#`_n1{2Q4& zcx4RYHq5$C=$w4d?YqG%QBlqC-f6d`^uxp@k;@^tr5RX^{eo|J6&ICS{NL7*zW$QZ zuNoLO8VVbz4+UTno%3WeX3EB^^iCS6B_nh6b;2Wi|>U$%;JS1-iThzX={Dzjmp;yR4u`zPO1D1buy ztg+NgvvwVo%@~q+_@KK ze={)m)RXM})}p$1i}ZB9M_})YS*^HgpWw{LjyKI`=ZqgLb6!MTQBu2v2ag_$>i;Qa z9Df>QM(rRPS!-e?1prr!I=%x~TjbF{;w z#^ELTUBvW!4er#UuN*U<@sT(obA@ex>`-{FeQbf7`om$f(Ex-L8DlsdoF%tVji|Og zarE`s=S7DyR(7HXC9t-*=0O%?0kyhSF805cEJOGt^B(l{l(*0j-;H}WRY0)EVmZkD z7iY4``}cO1mj;pL>9HxQI)MI8s}u!p{TS}>AijHB6~{BNG>o{gzb-bVnI|;Ud*U0& zGdCf{M^EoJ)ScAz^-NUQDCux#Db9kKIY&|yUTBKda?3LyvUjxxx}S#mS!=4^iO`H( ztF|IeSdBH0TgQW``V%?jZo|$2d`tvmW^sKg1cuNZYI6B;l8FKP=&1i$*WSg=j?cz^ zi@(Z7F@Qubesxz+dk~=;F%}8{`#)>^zOX}T;)FwbQVm{K{v9$RA($1vrKrB*bqZ3r-g{&;@sS-{0W=e z!%NNJ8Vn=K#Zh$Z6>n+xxjGK=2P@kONZI`0+Ay6c*vP9gp^ya5)xb{9}3L1 z#9n8GR2W(oc%aFAoVkdaOqbl)ZA={!@yd2Xubqp;O4fJa>vnX#Q-_plE*GDhff#a) zZxUY!2_1~j@5y@i%(27?9tT9-5;n$XHHknRcW*h65_Zb$qx2afMbVX9!`cSHpm1PLTtLB*t`M`wKneO~&%%TTBo+|daQ^%Fw&lA<vqkd;kW4~OEFBTkQUZ-HSdsHHOViNs1TsNpddm}<(Jc9n#*bL{rDQ;Yu+dXp1x@R2JAD;K!rs24nVjXYx2*?FOOyEVL+QAd?!^fWauJykJFH$!e~*ch2kVGW3dT1=vY=Er|` zkk)7Pr4Q^>>XTp}JM~0GJb^Jh77A{6qp(ZoOn`s@0c3VPpecZ-l<7x$ig5IReK#ei z8I2ipJZjNzA1jUx8hifJ%;Xd|b(=lU>;^jW|L?^I$>%GWF& zaVB4ESWyd$tVP)vF$$nn9wh=gXaN(tg>A@yqj-w@RL0J`#7*?x*!0W2-mr=0cZ{Vy z_)0CH3C}ty^Zg)+YDqTa(~3mhQ+KQ-)`y&LR{32$HSRiqDL*PjT&y7T?2AzdZ{$uZ zzJL~CpF!8^5Xx_t%%VRF^BsMdsg)%*<_oE7GD*6?tTEiQ^ZUGq%c!?e4xij;006SxK%ujS65-|pOOzRc3R~xJ8^g>K$lL_Vo^$a=eUgG z@;_ICZ5jyjYR!c~d6N4+nKBRzHht_#he_V8Yb&HS`K%M<_L*>8z-sK(u0yXA-0*>J zLkDI-Q14u%0Li2K62Iq9>Br{t5b$+6&C+$09y}8bpD`Ln+4gF)V3dFNYZyaKDomiP zo07pyvRCUz;CX8hrS6c3%X+5Ulm;+V|0MRZDO-rWhsPjlP`G^M%hu{66yQuNLj?ZT zMi#sI4ofzINaByh!)IO(qwEM7J#-+jSnp8 zEjJM#ozS6*L*B>)lkU0X?i&=A-Vjqu6aMg;_nKn(Jr%)_JbK;w0lZ8{#-VRSD-`W+ zhhI_dx=t9>$PZaa^|O147Th^1b4NFK0OMnJa>luW(vLIb!anXv1$utn-+q^(G1a1F zEhwtT?#zg8yt=x&%Bdo;OCW)4WTHlO;=O#>_hH_$)&h17SuOl~6f!#?cs5)<4CQaqxEl5#h zfCcwXa{uTy{58h7!iO@nF@=W2XvJ#>+K4}F-PJoE`AXi-_NDKF#uw=XBo+)hMrQHy z9J;A~ghpkezR#bMMAbS#{7#`2y~$4^h`wlY!Vj@+8huK2P$aU+5O1YgBu(Ez$1 zQp)u1d>$(aT8)bb;`}oAXLU}&%{;s(eM$ZQl43JZMaXu~3)@V)!WFFUJ$j`q(Nr&G zDer%k>8WW?Ha-=f)om^R)Vwy1@DEOb;HtA5ENm>04U0gjZ@+kFjU>f@IBQ9`(MRq+ zF6e*TF$QZmv8OR7i@Js*_3$})`urnrXTU}hJB7VvP=;os$ItBNdug0ocIo$?fXSoF zVp}zikx1r@xh24*4jV=jQoSRDmg)EdP+C|lvhXUa ztQpL!2Rt0n5A}!5J*pm2_>s~3qsYzTZ{-WC{~Vdb`(mAD`XVwM=gVVq&TAgtyZT&m z;2Q3}yH(3K+_)%3@K>x7nzu7-1mH*)Ojm)Kk;NWQquuEfPZW+`nH-!XLpgoy!eOqv zSlRgCZCwriODRU$lK@lD^|MMOLCoYUvMx8Ch@yHKs@wINPW~t5tq#W#%(~aZ`}Ozo zo1{2zf8|%Y{C+ndcuO6AsJCu7_2&!KvbwJgL;QzSWYx%7QX{5AOSS{19zwRqb-{XdQAj9+3VinZfaelVZCpouM*IAwj2x(jc-N4d2h*Q zYKx>{EXi`%?bdct0^p^5?Slg`D}u89D2mnyj=S!?xZRee$hYC2*9cEV4!S5#%*|sF z9Z&9E-Zr4pd(vD`@qKKNiIB*-L;x&Bj_s(i%&qa0ujGEZ?r$B)6y9}B)8*37ruz@Z zth|uDqk#^F#~grHx$cZTxkfyA=0Chf0T9AGP2#S_W!^2_9+&>CZRF;SZa3K;>VroH zKztLAk3(@CkC)#VpG4PCfn8_ea`=o%S$8KLcB#g*)5KL_rx_Lb$n4;sg1^9jBRInm zy5BIU(iFva4u=31U3td_L6wOp$DAK_*9##(m8P`?4)fz;Mc1me2-s4?B)k8zKHmyQ;E^ZCL9#_T5b1qO=<46-tdPDrAz7HQWUpAiIu6 z5a@i3Wpo@-xMNjVFz#iTmYJSB-Aq>>-q`}_9L9FPgG#l=lx$}+$WHvu^z7;bjT3~DgXWbL{nv)=KF=kZ=Nf^l- z-03)9Z;f>?NeP4$w*-p8mE(x7Iv)8twPZDNvJve7yuqQzd^rMLXyR^IS6mvKlMjVL zD=lOfZ^zTjICDVdSE?RSQI`se}(xs$_#*3>V0@0s++9$z)P&0nRxOocZp8x3i z<09Q9+w{!9|CRT@cou;|-0Lf#CyhKOaU!WbXiElM=$B+IwW$1(AYkof=&cwi-X}>T zheCv*S*(ibxTY~%kdZdRexm2*sryH~Jg}x*KN(f~*s}9?wtNBTuio zf4Rbw^@y&x)AxhRs6sDM$3Im|@bgS}y;|oOdpUVTnk0sKS$(z~%K)|0;(gccv8GQU zj$%30EH-IJ>=@tEB1JOsEyh|{1A};V6*m)sX!9&#EoA8!h&v!S_d#a;70q5<(m`)+ zFb(9t6*#84=^!nKQtlKJa-R}>;ydhPQTqLFx_;@1Coia%Rfdh288M~W#VtYps4|mujvO z#G(LpROTx912g;TfY;0hmKILlwX(yt|;xjkgtx!S_%|Mfj zFDHsP{{F<<)P7H_;g>WBJL>iqm+lLekyWfGX6|3s?iJzD&Kv+elN%DbGg0c@r`{fy zE7@;*+L;z?D9l8-b zr_Pa{oez7$EIa!8YeTQc8b3<-?aIF(CNmO7f%Xz1j&xNef?tEk%v>N_mG~_e;X=kx zcX$|IcFK{wy@$rm2*CJS(1dT91#CLk)E$1bz_7ke>t1>fpR z;DDd8cA0a*`r1b1iVewxN1#%9q<=PugF~XlY@L|U*;sk=)6L3XjPEIR zavvBw)3hyEE^LJlM8$rtjMkE z;DKw?3KV>u$%T{=7R6bEErCybo4>=F@6T4~*z$Kw-fSwdPzV(=SFAu7=wq<~Qdl`W zuEk=FJVvvN@$8D^>oe%DG7M{BfG523FOrxk0V04N5xhe;em+6H<4eX_`n!6y82(Xt zR4w=mY8dmUnYJGvqr(E**>JBvd@nCM;%Fn$Gn2>ayz>pV;pU@V_u|fo4q6H;`EZ~# zee@;i=AYq*Y@Bt}aSw!MgtOxJ^4If!iWv$6*_6M&L0vs`r+pjCdDU#Cx_aDa9oxCO zho0aRP1%SW`kSOEae_z)*D2}BV&2JgM`a*-P|LU0n$=KPT z(x}K3)p6X5P&}^BX;VaPzs|xH{vC_#rNQLi$VY-rxm9=g&IH(tCM9UUm2HWaQ|M!t zn7v6_Ar06V#p?o%cgO3Dj>9N&f+U9SiT)_)*6Ys`vsZRUzHNMdZqm5@^a^$mkLA?t zaNCcZ8SYNs0cGmo2&6N&OBik4C3uV>xJGe(4UB1#EmG!aN{C9IaMV%&+N02A9md1wK+q5B#KH zMv$c;TtaOBit2q&d1==_Glywl!8FF(QTA#B$WIIUx;JLWg5K!$rDLd;C*!F;RFBOyt4A| z$#NWa_YNFpKT7t?8@X*64;LU7v(aDqFg>7G@-{km)b+CA?h9UjIhbTnQs<-D`Ejd!3@NcC$%9fyoF9azpUZ$NgPD1^xxJ@B|LgaO-kLp@-W`zeL( zNG#E*9gR9Xbe*aq5(E<=_!cghOC&WDx&EqND0c5TvcrWoou|Vl-A~CzGqmRgRRCFx zl2r2Z(pV#G_)FLbHCBQ*E_l={#3@0lYNOurP9I6j&q1DYUbfr=GnfG4)^K{>#CNNp zs9Br~{~J0iAcEF&CP^Y;c5cpB5K05gdoCb5V<=ueqN>yna2h7>`ZQgz65sdgWvQw+ zr+?Ch3DBDZBnZ<{JLLj4{!+@~P)ws~dcif$H&q)nbQX+w{W+mtcSDvxFm241V^L`R z#tsY>N;GtKtq61QJN*JS%xG66bcIFo?JE9^>#Ez5VH~~#G*q%$-m6TsIdKqqoG*St z`mpDZ;W0$?oRYN}Q+jTm^sT)YfW>Xqz~Cns9=1AWq!uV^Ya zAS{Vq5YKdo7~`s~c6=SpO8^@W*x*O22%cz_rS+RQkXL9i z6M^et!b?3jEJBOlN!9K~S%1FxwgGLGi!=b?LXFg6y$G^CPS$NAe`8pvW=(Nv%R4G6 z^~3J|ks$aANpVIJbOh2}B%2MhsXQ<}5W*tblwxj~0pkha)>%9}JOb1ie2A>Ny84D> z62f0{?gH()fUnT)=o`|DPGE1%z>$XN675U_CAtb9f+eFpC(xbx*cAiL-6ET)s|a_u zXH1tucSaoehi{rDYgv9Dn3;6nNzDpit6@WPR+?Ve(|At_^^?gEsGg<03yZ|(LYmK) ztqu45=cUmLy%Wt+(D%|55*z84sJy8!a<7&OfV6y88dlV%0Fa&$ zSnzq<-h>%xFlX5AyZsFRXyLI=xgRlwPKdJ21=+}S;qf@zbMGEWlCnA;1n5z}VU6I> zhPxqaY0N-2b221pp@^icr1(rMtAnCw=?_&&tPzwXEod_vm%Y|Nrj|-x5y?A$LmSoJ z%l!KLVl|gGWsB$t*$n-%7~iLNHctX!p#u}@&#`oR91)>c3Bb3{%@wN^MJXvMq=>IV z2YG0HeO2eKrhR!)*iTt z757a1k9w?;MAvN~^yK(hoe$252ACswd?j^}z_+mWjdr;oCQL5MvH+)#Z-aMLZRVbE zF06y06*E@~UAi4v5@j|Ibs!Xto}UDC**v_zgR)-3C?RS`>@29A27;Gw8UEcnBH2s| zaLjoIlJe*~a@fn#s(d1r7>Zj;pf{M0d9MSLn_@U<%kAY@;Psp{dwdl-p$S~Uk=%*Z1uS;Vwf$Zeu>bXDn+kM4f;*oeCdpm z1saj%#YatCSUQO?rG^IbtwfcQ+_67g&k0Gyrf3E?@9P7@)$r+LlB;JFMIp{4P!T$> zut4%}fka>1*;B4--XvR;**(Z=-n}z%Q(oU+-C%HksS5_t;1hXAmw7tjICCNb~xmI^KV%A};u>?T!F>0HGNw_l|s7d34I6(1DpK=lOYF@;05gUBGBPvA6(DGn+lDa=(GFgF@}00{D_!i=us28jFo;Q0+3_Xjk# zpFi4D7|g|92=0}^>G;@=r0=W_zu`T0FXencepE!fQ@tBrU&#LXpPDwe>t}N~rJPJK z7R3*Rf|0+?kz$!2g1?@%A+9J}G+)%P;8iZe31Ps3IYHTNvuhYiUkY;LP;I-xc0g*t7(YaGsxnu_ZwQB$W3D_ZVZig i$fl|JR8ipY0fdg(`~)7|r%p6;F}T2oyC3!Mxd4h{}WNl{iC4h~=d`<;b~4Eyw05mbhK zA%9R&kcE4N^MHf1q+C>lQP5lz4Zpy8Ed6uAOS5Eq!-y#EN@{W_zmSMAaEWuAjH}_` zXyBA&rF6YlPjY?yO+H`0Zteg4k*n14naP&*G2vKcu80QJsSSFgBZYg5wT_F23u1a_ zyCUnd)eArmbRFUvrz=N|p_xtXW)n@VRTX>B`}j+pmcust+is?k?`ExjPk(yT z?WVQx)V}bP&rdwe0dCBXAq2>iYoJYdhj%{{W8UK^ql7=oKO~m^G+~NqLlzqEvrZ3w zo|Ss(6$BjNOl3j1|LHV<_|1|e0`^9G`_DgaOag;{{{8Azk6 zcR~h)_unp{&;Plj<^P8OnE00o?*F#Y|FZdiqxG3dj5+F4|_+~z!b1K+r zDAueTlP3Ej>N|)pxU#dOL)zZ;KRN^n6lRKgal(Pw^7cO$v8)m;7qgXl!rw#{*$`Wh zarqW~ileH9s6o=r9xp*%Cvbc_G*j9`=&cx~!YZ3nQ>G9V<@%%iIEv0+kO#cXzjhtHZn*0s;aG!e7}>RIWydOL-OXGYI}_vOV=S zGev>qW>$XNIVklQi%W zYVi{-7q+(EHbclk^s=8G5OH+}43XcyO`ni#yWRBTNZR<9|Mooy0#2$d9S_zrUrbRe zTrD%UriKROCNWqPc%8};^Wt&VeY+a}rmw`N_-;~W1$ z7__ST;^t-#vU4VABZM6k!BJwGkMu30?H?S9<4AFBFMa2Ax^7}`|7Qf$3n$5ZVneI{Rj5Ys%H3{H@gnC2y+X-%sf8Mh)&p|6-OFCj~7_upeC3}eiazol?_6YN&M~4 zm6}<=2V$}iaovNbR)kZ|aS$Xt&^bWpkaI`MtXq4%3@$zQ&Xnzo`=r!mrE3+k`~Ew; zPLzEb6F57cS5y0d_(1Le{u}dj+SrZsjL{4?#mXccG6Bb-YW{Tuw ze%;rB!eO@(Ud3A`TE<>}lYzDyOgiv}ytiXHHJA=p@70T_uIQl=-y7Nc2mOUNo576r z25Du9wt{C1m_n2C){i*7t`cP}R8Vj29q?HRmwHI$IuW4D|Hg>1pq!01G_!|$R7@&| z??eh3cyZOEt82grYQ9%Y6=_t{+NFf+;Htx^Ge+~2&Lq19Jpjqm%U`&wsOh4^^+-z* z!-zi7V7jSP2Gk*XzYp`%F*0J3suj3H#q{{0h_?B&{20{%=LdK->u^Bu&EAm)byDq% zSnN4JzW9-XxVYdj^SiyD>rKjo?7{kQqWVEC?EQMt%JYZ}T3nnTxrUcr`@-k=@n032&Hgq(c;bIa!Nd~FJ_M2cQRTrxww9+SN7ZE9-!paaEir( zL>K;^GS7aw896el{Hmr zUnmC}-@a)}+J9f13OzySBK5|qqpts6mt3F7yl~WJj`f7nX1Z+!62(QHJ-O!qKgZ^B z*bqj?@JG9`9I%7*ET)czt3kxwVa3htmL54=NT=|pvTfBnK8ZfcjB_n+DVsc)S*-}& z#U1q&8g$d4vr^kV$>Bkq1ac-i%|x7wY5(yaJOtE}tZ)LFhp$8T;r(!}acn3)DyZt_ zeh+}?EHobq%D!^SJm~T>7G{32v*uFh=00wmlK8|+nlBXm$PYs)+c~vtS)?}XJKa)k zL(eJkTZcFk_~K7jUvjisCjudIml9`mRx3;^}%*SbV3aQrX=Wcdx6$FtRS#vf_5s);lYI1?+#nyN@CIu3_&##&@7KFM~CG|oty*j?bGLNXjbof`C2MhG?V zj`HYvDKv)K!NegG0$yNp7h(LGN4Ey*K@uX%Q1SOosYvGgNaWg4@&~s&Ax2F5qJlr~ zmuF3aUC2MUh(F$HA9cJ9NvU+LCz#b!;s) zNcZhUUKnI$q5WZha{7M0QIbu|p*w?DFF&0nugDK;)AGRUplrk3MZO?8U+Z92+diLd zH!S+9TI~y#K&6m6=13LE8bE*`Kt6jvkTWD}OqmO_J`^mkgn-NBt}ViG5S(eT`4kr{ za$XI$nTwu){VZ2p2RtAkGoZ4mPN}Ft70C?84f8w#d{^_G;d(qa^3h~OKE;q|prtWR_F>-< z;2s!e472pFXjQ9dRod1m<>RVA;csnh6B4R%b5(Tb&QOJrm#+->7kQNpRF-={afa`@ zUP)@In_mS52WCO9^P~U_CGzP4L_Jdt#TKsLhPkN^adm$L`H&TxyVUaGe>2Os_;iaIXSvFKT0GHMf&MPEH9g2zzEN)?C@1-1r6!iqt6&ZeOWN2cY~&imKsA5+@y0@dz)xa(e15mnv**(4 z0~F_uNnBx+?WFHf&co5VWJtzXPO(jpny@^q?B7f*kvlM2DmVqn)xiJO0KkjlHwK>- znUHbXZu*qd>+6#!(%2 zED>|bbgwBYKmA9}$FZWjl(WZ!C1aZ?bbT9qi2@B!eF5V{!YzSrU+Rk6e=9hXy(ihd z0R*a0DDWkJA@N<{aknMeZ5bw!aZTHJvxfrl0&z$|ls#%*3hBBeF^8>cm_VNgId>Rb zxgRYd9|xCCBRh`?{*Vk@apTead8^lu@cvx>?pB~A>aUO^o>}A4B->?z=_P}>=~m4# zM@ko(7*xFcCmDK{WGY%mzWc@W2fr_&MfnJ;m7?D`!o@-63i!EHp1U=wt^4+R=uF3> zOmcf9@iMqpj#2^BfF~SqXC&oH?CeQ+magnHMuRry9&T#B?#R%m?P^Hk`zanj_C{UbP9bLMcT(JtfNr%(LhA}9^1)284sTDd`@IjC)f+n7O~Jv7WBCPJZ1 ztkGU!Y?4Tx8F8z@LcP$OBRb)q&qDW3coDh6IBBf~4+z7rOjg}G$eaZD-yi#8CTn^d zYy5hm@6dlSF>)GpdDr9bXDok$Il$RE3pTS8AIybMf&pt;ZDcJ8xS>aVmVS*IIVxukb}1|EX6M0)g+Ext^Pign zE2lY_MK!4;!*;%G(z3uN6|oR0-VQ(Y@bB^_n(QdLO`L~#j=mKhWydR?(piJ!9|4Qb z6Sb@BXp%W^xymxV+AHVA)^+Ca$15Ih>NIMmYCb185Kd8|4@*dt5`OyrVaHC06{?%` zvyT3c>_M2+PlYRYXby*o3072c~y_^q7FM*sra>yVoYG2 zxwVnCVUS*EQMO+}h2ozw=2PtZR(A1%%ym;UNyYi6EVVkq1>Sbj08Y889w_Z5HRbmB z*QDrgrHr=K0&Jw!*ea?N$Qt)(SyR<_gnWKi0XkM_EIRWapSj^ZHzgAO+V<@Q+a0^^ zmRs>C6B1AUM3qOvwMMK#AdT7QNyo0icDx^ig_l-PV*}Ezct>RoLLq_UA54NCUu)Ux z=1r$%*s2zz#N?{w#pEDT#4^SS%9GLSV$;-ESdN5{ zkD&C>C3pGhe0|5Pz}=C?5@X_97ZKHMkP=Z=;x;Z(>9gU9M4+ENEPAo)veV^|z5fQ+ znQB~aVKSuN(u&0dAGuv&PU>Z|{y}AcrZoj}OY*vAk}~9y4%>qnYw{l4$zd`UuaG&(7JFT%Z+fcS$^du>1aNoeyN*HPqI$S|=(^y9NoQJtr^g-cNs#pAVdBUt(#LW=+%;x&SF<_uz zB2WqnZnIOVE{D6-eMhuJ_ckrn^U<0}-n|93Y^r?Sb4y_{MyN&DRP(<&@ulxZa8ar+ zJ`@l1n>y0MsFJ}EPvyo8pt%&Q-92)Y`=kkE4o9(9*U98MPPc9)eU1{|dzZe1duRe} zQ6tcwWYrSS6(X;#k9Mq)wYHh&HxJ31l&CqAY?W&w3<$n67+>bU(cF8gE508%yg8lR zgkp2pT$IaF)2ox1a$O<3BIUtR!Rx_wFUWa{xH|TUgNyv_j}^bHPph;PxA{W52h)S> zW`3ftV_#y0_eK#D%{r1#p$l9e?hU(2bUEm$p-jN+&b%{Yk^a!Pcv5}bejennt?W-jOd zkzejOB3JFiq;|me{r#OL_pck(I=lJU?s6(*|JAs^R>^ScY&)hnPahlirC3)(?}iH~ zRUC)S2)voWBXIf-H|E;Wo+v%CC+r^yLA?ab$vs5g^5s_#ec*J5GJ)VC{?fi|peerw zjry$cLc|b>m+~3(=ip|gk%^cgnn|!rMLs%)+v0Cxe#O>(6hbEsOjH0n?{zFU)~IZVKGOSF@~#1dWg&$?rUKj0JKH1ThQbAqGN%{001~mEez)&-8H^dNRQLPUiM!ygz9&txJC}bQ%|9Z*n z5q8t~b>tW>^NUt>aE+qa-<>AHS+lu?YTUkmnp-iZ(_8N}Xd%59 zWES>Vxe1`C?rSnvneLR*$|!0gazSqX+j5N{skhq+NrCwhx)-qv5~PTE@Oh+1KQ<0n ziNS8X=TdfGFnAdcY%P;I1u1va6F2?Rvccz{?b0i{5uc2bW5w>gV%%9yrkuKPLYQfK z`mV&z{p;!dD(TE+?inszzvy7~$Pr3VVEd%Q<0NeQEu_vWsuy|Q$2mz0k?s|$PCqv+ zCE9IahEulx5xY87{7idJ`?hw&if4XiUGccv?PTv2Lzm8ANibhOjn_w!(OME$anrvzp`l#!qjRuT0;6h=z#@+Ag7ne{f(J=F?Rh+1 z&=JwIQ^rUHP{(^sd0gZLY3_z*DT;Nl!n}bOAD`>Y7Q($ju(X#r({Dm0M{dapNJtK9 zSN`3R6UXX3)d!6&(#(R#(&d%7 z;gRv$${Ft_ai`e30KVN6M>`9H&VUR#6QTI zGp1SUMP2;kfA>uxsh{(02F*zYs;;qbJ48ZV!d?#=UFP z@L3;P;7$hOYt#VLE1mL&vjc)Z#(t0QBKDKASXd6dR;5gwD(@J0COfyov;A{)AO2(TwJx z54{CA6LUR8QtzsKw4sTK{=K6%`0iuPNC3VWj!?9syoYL8vLGP8I@x-gGpx19*Q0|& zlHsGf7tfqoIg4-lu=K6AU5fng4OZ4Oo-A`r(MGC+N7u7%Z74B-`B5sc)Zb^XO-vR5 z^A`ifdf;;qtAzGEvLNwp9lAXhK(n@A>Tc~@9aYX zm4$_t@fo;yu6A#&OWE0Vw7vG+Vj%cX2Pp8Lol9lqpF&vtKAvQnb_B2^j@Cp#Z8#r7 zyHnJm@eWb^qt&{uyRfU(l~m-`Y{96+pOc(gfx`Zowp%mSb5Upt>!rq{qxeS&r9yIvo%Rhi-`D;YMw!7q4fXxHK@Rvc9rEoOqiT3qzB&e71DL`|{_CqghX$vR4UzMP z$fr*CF!N4~VYv^^t9y`6x!oj|7|vsu!Y^w&PgiYfIC~vScgC;=6)>8iQ-Gk$wUNcm z(lvBcH|BHu5hJT^fXX95JPY-3!103Q#Z_Zql6if*R9CSvR3DGgu;QFmLH>hj=lKO) z`!JTqw*{pgZ=R*rt~@x2H;lX65%-nDC$#v%uQB~_7c=u3_B8HWp-h4YLkoY);N1}G z8h?5Dq2DVS)9d&a)oB6sl7V#{+r^J(ueB2}r3oFDDN= zYe#HB`c7R{GK4DauXRFOsQyBCu)c`XFY929$Fa-5oIe@Jr^bHaRU-GpqUEaIyBIm*@XS5#57w}&0C*sTRJ7X`Pkbfw0RjMldnOoNk3w&O)9qh=TrjmuPQEjLrZCSohEDk1Ueq{73JDoo-3~`p zKSfn2G437uBDf$b#_%#0{55k%`DVe*RRyYuWh3U{)nHoVdWETI|r`anT*DD3%F6c^bR;@0Xg+}=}*QP1j#s^tJ8wN zNS%2Q)uBOA#2Hv5Wpl)4KjYo77Cq$kA<aKOGWC4IP^<*UNou4TSPD8T+~W=wqAMUKpe`h*SNlc{7%go3 z>%`{rnN6#`)2u<9Idl*2BEElQ_)!aeB%E@kxoNILnhg;je6+eJ>BlhfmGJXp1?`#! zR%Rz~-485nK1NT?bjZ%cs)wJ-uGAmHt(zMpJ1OEf!%9$&%Ru9|sRF}~H~ zdMl{#`kT}o7J6Ou{aqv%J8YA2$QSjt&#}MPm~EG|Tb}5=d8$(UdZ+hw!h16E{3_(f z6V<-denpFzrS(mq?oZ~Pb68Zan`&J-`oFaR=%2`UKF*|9S;_4I+p2}z$b`6vc2VrQ zy~{>#i5PE*QuTJSvWCxx{0%v2o%RpV!_Iv7tH70UYj~G&k!JEaCjnynC!(^S;RiTR zs{iznm3v0~@fR!}e@7q8Jnr7xuRbSz<9KTQvuc5&%Oh`~_0R8k(VAT~*i}xcf0=dC z;j_2sW7KLi)H4}y%o`BJ)Emd#aWLPm(JN~b&v=+1P1@ReO4!D}kVm})eXtvk6r~JY z(v=sg(d*L3MfuQKgd;SCgZ|vii+^~NM(a}l5ocVYh~Av;Je6t3ow0@mf%y9x`qV-d z&MG<{og^bRtINuhN@`K-E<*<3XnCl%vT{)E)A%1Y}eI%SH8%0F`UD?iJ=Y3G% zuwUEeY9Z}`{4_i0qJ`3!okHZvZMp+c*~?aE!Yu6W(cQ*yel|mxPJ!@7^o_x*=clW0 zP|oF6OH&X~L^8HOL1+_kox$uX(TE&9{#RY#&a5DOE$O{!36YKG9p|mzL9P7;%FFSo z6O_CyzTRKv{^^h_z8+r*AyULYF%ar^NMl~6ULL_|g0$}V^@#B}x^os70NK0TOEXHu zN*+HabZHD#=oT*#Zk7P;t%sIw<*{LVS;!Futj{#&F(lZ2kzxp`JUc?ZOvpEsN``H}wa zcqWou+t&+?!Yd=G#ycu?d>jdcY!d+KdJtyIL{!a1!Pp)cm$?ox)`X>mpBzYTop z+`}HCLQWGq=a7*Clk?2WmsF45agXnu8w0+k3VfMgkiWV;Z1L~5Ug=%xBUwrCHml*N z`=^iq#|G&uom*X_hMtE{jyax2ZVry}@x4ph_tM~E*(f?fN?*-Sh4# z-<>W`O4fX1APa4xMXQG4hJUVIIHHaO)y5jBw=f7{?;z+>~Z8Bvce zIRm?^;-NP3XC|LD@0*1<{Ny&C7`o5vpM$6X9t}{i9g>$qKE1E5OZsvH%AL-$qeeS| zm+kvm7Qc8Jx5k7#bLlpt5RuBO0`3vo-SYRmZWFxk zc^kgpk-RSSr@%tuyjx<8q$80VJ5a6Kn869iYQ2Ui+@s#Bj{@Eb+L4#EV~fQ3Midp0 z&Xt%z6EGDI!Ka(anzt%;f8j4m+MqmL&8JJg^V%RqcWS@D7PJQIFO*#`}@CfG=?U>Tgk!%7*Lz<@tU z#dtfau7JlujG$WU?Ode2hiZ7XkV^qSZ%auv?(;pBP*UG&az~9MU;>@Ui z`0&1yCi6&s8L3h$F9+_1cF?go^uZzQZ<(~eSO%mViE81lRe;C9*35U~_PsrGF?Mr! z<;VKKqvcR`*FzM2sl`TJMuJLpBKh(z?gYBqJ`t)!@wlMNe&UDVW-&ey0Oy@Sps$;JC>X*$fpWk)@M>g|%Vv(IU5=o^V>lBrs z&oEp-ivA_6i|AEqh%A*~z7+tz**R_K<4+nU-c3vLI4C?&D@oZPApig`T)gI1NisKz zNB@e$P5mVUi&u2Ps-yx{3ljoIM|+!sZ(IvuPz<(!9Tr*^hit^}3DMZpG+K zCrl5ZDd1uv2bOJfj?Bc&XR=ojPRg#vCNw6o6bKI_iwx4zMJNA>x}jV(irL>=Y7iZFP==rWEULacE&N z18PY;yvFRS2UvUsydhe-pHDu`Vi3+>6PtN;0evAjwyXt*?l;Mw5Gdo=98q7)gcY?>Z$PM{Tj zQCry@fvET)- zj*}ExORdRWETsusT6Pz?V6*Nm^hzMLZ1CO{-(n;zQCJJ)uop83_>)<+W+X~_9fHmn z85?6h<;YXMhj@yQCSPFR;LU!j{N4x6YpbnYsMEIE-tX;1$_05m&j2Umyf@BM2t<1! z@A|Dt<3ANc_H4yw@(weF5WDGuz%@EN>XmkU5aZ&_e49wiU)|L1DY87-FSuTt ze6jnP7Jgdm;W#Ap^zS_<@hqw1yp5Sfg@kNs@j9s#v6@8Zu5%d8QoT6%C#~-9mGEcGANX2>jxjw=241_SlmMFgx zUC;ddRrg3ZT@~TfZ3C0oAg1#Q*Otb(mJ$~$oU~viw`Uf}HEN#>!1c7qi|bOxuA^wCbyGy6pLOnNWPU`ZKCD&+p4+zl zP^B=&$Z8aArC{qkpNN%4Yc4RS#Xv29x#HDWZ1`JojV=T@vm$^rU0bP;W*8PqA#r)n z0;B?*PcU*YJy|Q%5&a6$t(;wA?cCwSb|SUkF9yS@M2Fva^W_RL?}%H4ALZklrPBE> z&IHgfV)Hbl(sD8S(gvFXU+L+lY8vTPwD*@5;yG+S{+0tL-)2+}QF=Eco4l0@XqMp_ zEe+Ne_1`VdZ1kmvGn`f+*`=aS2>)V9ZWI~fz(hcJ#I{mumQ*S!}yYx2K{_{qN$jcIeV2rlT-}_fB;04{^ z;w~$qbJaPbUh1iZs-NaoalxhSyz12ao5LIQa1r~MOb7@&|8~pc<=HY4U~epSW3h`H zu^PRuS*9x&$xfx-Ok@_<9@B-&eOS6b_-LRs)X z>fdZdLBN+@i`W)Kcys#llqXu-c#t#&R)j9oUZCWb@1ZtI6^zaGfz=65hG|%U^C3l^FE*gi7$<^JCwL zH>r>fr;GxA0qjZ773|*>J9$|tN3B_`6|3BvY^)bt@~@J5XAdZFYRxnwIx?7%y7(|m zD~}x}AIQg96_I-P4k$>xrQ>QRDr3_?(CEnWgLflxsj)})u+h)TBb{;98mQQqp@Pgaj+r<8 zMrL0lZqm@#6GYF(FvD?h-u_tdPp=|wDL9w6Fpo?$Jyt5^?x!OIyt>~}p`dqxsZOMI zqi2E4AKtviw?YYDouSDx_hy>!W$P*eb%g?>m2Z^gP z>GNR$c#h`$>IQa~q57R@>u4d)< z&6!TA_4kT-%L8dmlBz2ol4LNxRM_SJ;Kne_&F>?KI>%ASrh%8x{JMU#cu9_D0A9s< z8b|!5HL^Z3TADi?+pQ&0MeGP{3?BTTAc&L`xAx=rtJdyz@=GY24^%Mnj8`R<3)^GY zPLJG!+vXzsobyH za&?6(xH4>|*2wVjZZrwE~i9<;Z`FP_#HA#U`i325Tb`42(1wOJ(N7!Bp)M-&Ll5mik{kQ|MwD_jHCm*k&-9o>F5&t~Y9_`5ieY)bTA8KSd65Y27#Fb(y> zICdE2WTWOY)j4`&3>V|VW;HC7`$h=i-F=@GU>}x`=Lt+Yt8BeN6?KbLIkq)!l~#)( z{u`j&T>WYJx9=TU3d!~q_L7zamq?8hrIrPUSy$Wg>Z&hmT~IS_ljS@yoma6hHTTrE zPNIDATukmH#&e4kbKXW_fe$*E_?KWg8Pp8=g5?Riq;Ns7XJ)2yzR`!Jx{{tts0ni@ z;=`plqc79IQLba!(BD|k(lXOBB)c`toFS*m2TZ|yUtNQrx9k{dj5MEV&qeqm$eU7d zihPMr*(AH9w*Q7=SeYzy2LW}tCN+;n1?9&X$@X#GrAXVxjDvbdB|)OH3{6^g^W7Bm*bZy}6;Vhp zxiNp;jS1|2SV&(zOX|UU-VsJ#&8PI>-FR`iN1v`>L%Kj_Xw-YAlKzW*o;ma zUa8BCV|#`Gv8gi=f1a~Nw=T|A_bcuxj%3e|ymvM{qbmeg0XqXO6*B%R*o;?8WANx6oc%r7W+V$BuRHw5^X z>o$Q}VuuJl#u)KfITdVMSfLIo%RHVHef+o)A>ZV$g{j+6GxS2wI)A2&BNQKn-c@%l zkGk#%T`+;DaXbXyMdq%!66l3ePDazU-?EsfP>?(9*rm#zQ`CQ7VH5>OvLm-6#89?p z1^AKdmIa$(QtY1BPtp6PZJ9+0djD{Rl?uWtd5^wDw45q#yDf5_?Z#9*e{ra<16s||q|&aqP^?S(tq6rEl_C~yL?<$FglIesYf z*eFLzouVe?vtMHDh^aJZWD|j$LmXyZLr}#BM{Lr_x*Aa^04DUwFAP97YCPo0!@i$o zTIBPE1>xMIrTsLml5ATH*z1z$w+fB+cWKa#Zq^_R>tvXD~B= zd4GP{^~N%Z9@DKNYTj62(mkY@X$x?FUaGKka&eUDvX0$)PAu@w5C(NAgLYsJjpgJ=g7M}-&`7fd z+c_L7TA%!zw+BN2K^S&LwjR`(IOzPBZsyM@LBGC*LQIjE^j&t1~{rpBGgP0&M zqCk@sxt*G=3uk%q;+GfhUAXMS!wz z=0l{_=au`(2LC{#$5@#LI3Y~0LxBAJIxLlCADkpK`9r7R{*D}28IUBmg4a~PigFE| zEqw2lE*t;2sc~O*kxYVg$lJu@^eIdsyK9{vQcM9QW z>aIh%E5#l9+;@6%jYCH9X&}2Qv1)`djQ2rNkL2}p!L=;DVehR;_+Pu@!gNB?xV9e5 zlS)axvQ<+ptL%Jy{s{Jj2)tvv%yzF+RRHH!%lq?J4YOYlreoZf^!Y4VKb zYUEr5c9OL`^Y2{IIV0$WZtlxHiDJ6pAowJiO`~RDr{%uCK@m?CGAylD`St!N4oK$7 z|BAmyIbRkW01%>RL%zCIDxxM1UC(9h<)^W}cACvtqL%jY(V+u-&&9}4VESwRa?aAD zf9%D~Fjr?>RCfG`u#!PR7kgTmWYP)g8)Q_An`tCDP|VqQZun467J8~&Pk$>8Gz)z! z$sx!s4aqre^}6jb`K;qa;akp|1VigL_SU&INJ=y)6o$_;ch-~9!o0^LB-T&1SOKLA z&-*O1=nlrkOE!oJ2tKX-y)i@c9xz8h1CU6MDX+{U)1zJ8935CK6@!q;kbL*vDD2zL z7Ya=xduKS$+WK#-@Fz-Fr&lWzJfP32A`HQGq2D!yv95xcla`7W-=e5rk8zQwm^PRj z0x=1!;r#H-7X$^>B@g@szX}J<$NY|i#|n+e2lafrYb(hcjE$}^5`3x9&_SEMP0w`0 zK1WzjBO}*3!;2)OV5(nwWK9k7J9keuk-%gD9Dbn20w(te)HSk+;`$97SyHfGjExaR z7vRP>b#likNp*-%SxmbZN45sv=^2k3eGbKhb3CMZz2)J9nj41qKM&D(p}U^u+AjVe zFTwQRnsN!x5%vW29v1jnu#D!hj=n(h7Jm$r;Y%r!s6Lxdhei>QG9PU>2j^Bw5=5`T zR?*O>sL!2m)k1kHY*2maWP3Dnf}1{YG?*#VY+cW#y^`V(Bb}q{9FOVQ*+0ud&InJE z_%J@^^|BEVQZe^zWVMzFF5*GuK!Q*vyObR(-g8`+uhhHgwP(bzgIp<7-c4RO%F?~6 zCi#mzB8ch%cY3>&L{9{NKV-7N6Qi%L&0$Ka5VX5L~0t6~|oPr!|6FZD2jGZIFzQhvj|nw?DpY-uEUj^>4xsrB#d}ZcHG;iVY^e zQKkTy5>y_Ww(_`y1fZ1nfwCI3>g1a1{X+}XcnKPg-Ir)+c`i2=k}R0${wuQI>Zy{i zgXu;PV!0DvAMS8my0-FOQz%3`BrO0F8CQ2wfsEtK!Dr5e7*8}VRHMW&R)K1m-hP&v zNsUOq39RF{@R3eSqXM}%@`9|s5OnnX@R^QuAv@1i62Ung20WPmw7P1SqG>D@g2@b84gg7cDs5vRWF84om2e))Nx zd(!J!*^4EC!SJ__VyhUIibwciAEP(AQ#ObKnTwqDXV|r+dN_CTdHcXI)Vo2nMa~FL zYXHx29E7vms2(GG(Blr>Dy1FWg}~0;ATzV~Bip$?^qEE@GmShpUb7HoSA`5?alZvx zW`Ld(HM-Ataz6KKSq8p3}>1rej|w zW_yquOtJSP@7BfJcLBkDB*1cr}lN!;g`t#!Whi-(=8bSUg>deTA7hnIj88{Iv( zVs$||cS@=)34OXW8hnV`jN0!^v*{U>GkP80y|Ch-6i#-d-&r{1J_PE77u@gHbG zlVQsyKWHLY17U>k!aiOCU=lGpCC;W%x;nBLm<2l^OC!)L3V7jLUU8GBiwP{K9~DV> zc$-R&*V(`&*Jc!Et>bu))(h4BIF!ekn=LNMHnz8b^dj?~ohwzSik#3pwXNWqOHnrQ z`~I!!-IMlVfhORT&69tl_$Z)T1SJ8h*ZGjAni_ms(DaJ=XLLKh)TH9d6q+{q7eh{T(!ROP?XF^AFj6Q zrf)5{+I}5H9D)Tolf7~-uel1%Q`%R%So2ecAPgmBI)e9{*?2XeHc%``&EPg8FZRhJ z;yE$Y3QCPS^_a!wB>gsy_od!{hQ&cAEIUJ5-~Pv{bXT`#+=#*{1+WLJD`C0VgJD`T zlDsHN=m}~B^@0SXF?7f8$2qSyWxWgWbUF*ydR=qY>N=7(g56%atCH2u ziR=!cv=9?sX)m{JAzZe^$G#)HU%lg$b0yMtus@PwoXVP*&<0hy7MhyTXsfl-%_b#6X;!AiC667j#D%El$BB>f;2-Zg$=l=<-Fck%RAKUE%Tl8Wi`)TS0W6!g3&caTNcU27qmJw@RRAP*! zg5fi Date: Mon, 23 Jan 2023 11:20:38 +0100 Subject: [PATCH 04/80] MOBILE-4238 Siteplugins: Filter text for collapsible title --- .../pages/module-index/module-index.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/core/features/siteplugins/pages/module-index/module-index.ts b/src/core/features/siteplugins/pages/module-index/module-index.ts index 0dccf03fecd..053eb374f16 100644 --- a/src/core/features/siteplugins/pages/module-index/module-index.ts +++ b/src/core/features/siteplugins/pages/module-index/module-index.ts @@ -19,6 +19,9 @@ import { CoreCourseModuleData } from '@features/course/services/course-helper'; import { CanLeave } from '@guards/can-leave'; import { CoreNavigator } from '@services/navigator'; import { CoreSitePluginsModuleIndexComponent } from '../../components/module-index/module-index'; +import { CoreSites } from '@services/sites'; +import { CoreFilterFormatTextOptions } from '@features/filter/services/filter'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; /** * Page to render the index page of a module site plugin. @@ -38,10 +41,31 @@ export class CoreSitePluginsModuleIndexPage implements OnInit, CanLeave { /** * @inheritdoc */ - ngOnInit(): void { + async ngOnInit(): Promise { this.title = CoreNavigator.getRouteParam('title'); this.module = CoreNavigator.getRouteParam('module'); this.courseId = CoreNavigator.getRouteNumberParam('courseId'); + + if (this.title) { + const siteId = CoreSites.getCurrentSiteId(); + + const options: CoreFilterFormatTextOptions = { + clean: false, + courseId: this.courseId, + wsNotFiltered: false, + singleLine: true, + }; + + const filteredTitle = await CoreFilterHelper.getFiltersAndFormatText( + this.title.trim(), + 'module', + this.module?.id ?? -1, + options, + siteId, + ); + + this.title = filteredTitle.text; + } } /** From 250c6c1db8492b64f5ef23343d30cdd05b894d62 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 17 Jan 2023 17:14:49 +0100 Subject: [PATCH 05/80] MOBILE-4239 core: Remove cannotconnect version --- src/addons/mod/quiz/services/quiz-sync.ts | 3 +-- src/core/classes/site.ts | 4 ++-- src/core/features/login/pages/site/site.ts | 6 ++---- src/core/services/filepool.ts | 3 +-- src/core/services/sites.ts | 4 ++-- src/core/services/utils/dom.ts | 3 +-- 6 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/addons/mod/quiz/services/quiz-sync.ts b/src/addons/mod/quiz/services/quiz-sync.ts index 68568c798d8..e6f799dc81e 100644 --- a/src/addons/mod/quiz/services/quiz-sync.ts +++ b/src/addons/mod/quiz/services/quiz-sync.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import { CoreError } from '@classes/errors/error'; -import { CoreSite } from '@classes/site'; import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; import { CoreCourse, CoreCourseModuleBasicInfo } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; @@ -314,7 +313,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider if (!CoreNetwork.isOnline()) { // Cannot sync in offline. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } const offlineAttempt = offlineAttempts.pop()!; diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 6b7fa753887..ffc1520504b 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -806,7 +806,7 @@ export class CoreSite { ): Promise { if (preSets.forceOffline) { // Don't call the WS, just fail. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } try { @@ -1694,7 +1694,7 @@ export class CoreSite { .catch(async () => { if (cachePreSets.forceOffline) { // Don't call the WS, just fail. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } // Call the WS. diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 4388745aa33..7673fa6fc7c 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -45,7 +45,6 @@ import { CoreErrorInfoComponent } from '@components/error-info/error-info'; import { CoreUserSupportConfig } from '@features/user/classes/support/support-config'; import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config'; import { CoreLoginError } from '@classes/errors/loginerror'; -import { CoreSite } from '@classes/site'; /** * Site (url) chooser when adding a new site. @@ -420,7 +419,7 @@ export class CoreLoginSitePage implements OnInit { text: Translate.instant('core.contactsupport'), handler: () => CoreUserSupport.contact({ supportConfig: alertSupportConfig, - subject: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + subject: Translate.instant('core.cannotconnect'), message: `Error: ${errorCode}\n\n${errorDetails}`, }), } @@ -435,11 +434,10 @@ export class CoreLoginSitePage implements OnInit { ), ].filter(button => !!button); - // @todo Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0. const alertElement = await CoreDomUtils.showAlertWithOptions({ header: errorTitle ?? ( siteExists - ? Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }) + ? Translate.instant('core.cannotconnect') : Translate.instant('core.sitenotfound') ), message: errorMessage ?? Translate.instant('core.sitenotfoundhelp'), diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 34a3627b906..1751636f0f7 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -55,7 +55,6 @@ import { lazyMap, LazyMap } from '../utils/lazy-map'; import { asyncInstance, AsyncInstance } from '../utils/async-instance'; import { CorePath } from '@singletons/path'; import { CorePromisedValue } from '@classes/promised-value'; -import { CoreSite } from '@classes/site'; /* * Factory for handling downloading files and retrieve downloaded files. @@ -510,7 +509,7 @@ export class CoreFilepoolProvider { } else { if (!CoreNetwork.isOnline()) { // Cannot check size in offline, stop. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } size = await CoreWS.getRemoteFileSize(fileUrl); diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 3550b627749..5a4b7bb3e23 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -363,7 +363,7 @@ export class CoreSitesProvider { if (error instanceof CoreAjaxError || !('errorcode' in error)) { // The WS didn't return data, probably cannot connect. return new CoreLoginError({ - title: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + title: Translate.instant('core.cannotconnect'), message: Translate.instant('core.siteunavailablehelp', { site: siteUrl }), errorcode: 'publicconfigfailed', errorDetails: error.message || '', @@ -374,7 +374,7 @@ export class CoreSitesProvider { // Service supported but an error happened. Return error. const options: CoreLoginErrorOptions = { critical: true, - title: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + title: Translate.instant('core.cannotconnect'), message: Translate.instant('core.siteunavailablehelp', { site: siteUrl }), errorcode: error.errorcode, supportConfig: error.supportConfig, diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 36a155f72f6..bca8e070165 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -57,7 +57,6 @@ import { CoreNetwork } from '@services/network'; import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreUserSupport } from '@features/user/services/support'; import { CoreErrorInfoComponent } from '@components/error-info/error-info'; -import { CoreSite } from '@classes/site'; /* * "Utils" service with helper functions for UI, DOM elements and HTML code. @@ -1373,7 +1372,7 @@ export class CoreDomUtilsProvider { } else if (this.isSiteUnavailableError(message)) { alertOptions.header = CoreSites.isLoggedIn() ? Translate.instant('core.connectionlost') - : Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }); + : Translate.instant('core.cannotconnect'); } else { alertOptions.header = Translate.instant('core.error'); } From 5f9b4c52186da26fa294b8a681046ea8147baad1 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 17 Jan 2023 17:15:00 +0100 Subject: [PATCH 06/80] MOBILE-4239 core: Update LMS versions --- src/core/classes/site.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index ffc1520504b..6fffa7b91d9 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -106,7 +106,8 @@ export class CoreSite { '3.10': 2020110900, '3.11': 2021051700, '4.0': 2022041900, - '4.1': 2022111100, // @todo [4.1] replace with right value when released. Using a tmp value to be able to test new things. + '4.1': 2022112800, + '4.2': 2023011300, // @todo [4.2] replace with right value when released. Using a tmp value to be able to test new things. }; // Possible cache update frequencies. From 6515d03f7d7852c3207606fc698df0d1f24fb58d Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 17 Jan 2023 18:08:02 +0100 Subject: [PATCH 07/80] MOBILE-4239 core: Enable DB logging when needed --- .../tests/behat/behat_app.php | 22 +++++++++++++++++++ src/core/services/db.ts | 3 +-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/local_moodleappbehat/tests/behat/behat_app.php b/local_moodleappbehat/tests/behat/behat_app.php index 168bfa9d55c..7a815ec17a6 100644 --- a/local_moodleappbehat/tests/behat/behat_app.php +++ b/local_moodleappbehat/tests/behat/behat_app.php @@ -19,6 +19,7 @@ require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/behat_app_helper.php'); +use Behat\Behat\Hook\Scope\ScenarioScope; use Behat\Gherkin\Node\TableNode; use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\ExpectationException; @@ -45,6 +46,27 @@ class behat_app extends behat_app_helper { protected $windowsize = '360x720'; + /** + * @BeforeScenario + */ + public function before_scenario(ScenarioScope $scope) { + if (!$scope->getFeature()->hasTag('app')) { + return; + } + + global $CFG; + + $performanceLogs = $CFG->behat_profiles['default']['capabilities']['extra_capabilities']['goog:loggingPrefs']['performance'] ?? null; + + if ($performanceLogs !== 'ALL') { + return; + } + + // Enable DB Logging only for app tests with performance logs activated. + $this->getSession()->visit($this->get_app_url() . '/assets/env.json'); + $this->execute_script("document.cookie = 'MoodleAppDBLoggingEnabled=true;path=/';"); + } + /** * Opens the Moodle App in the browser and optionally logs in. * diff --git a/src/core/services/db.ts b/src/core/services/db.ts index 036c8718cde..87eefd5193f 100644 --- a/src/core/services/db.ts +++ b/src/core/services/db.ts @@ -18,7 +18,6 @@ import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb'; import { CoreBrowser } from '@singletons/browser'; import { makeSingleton, SQLite } from '@singletons'; -import { CoreAppProvider } from './app'; import { CorePlatform } from '@services/platform'; const tableNameRegex = new RegExp([ @@ -47,7 +46,7 @@ export class CoreDbProvider { * @returns Whether queries should be logged. */ loggingEnabled(): boolean { - return CoreBrowser.hasDevelopmentSetting('DBLoggingEnabled') || CoreAppProvider.isAutomated(); + return CoreBrowser.hasDevelopmentSetting('DBLoggingEnabled'); } /** From 18f9e90fbc9dfb32f6a3245135971f4a3597527d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 20 Jan 2023 08:10:03 +0100 Subject: [PATCH 08/80] MOBILE-4234 mod_data: Correctly handle 0 in number fields --- .../data/fields/number/services/handler.ts | 8 ++++---- .../mod/data/fields/text/services/handler.ts | 10 +++++----- src/addons/mod/data/pages/edit/edit.ts | 10 ++++++++++ src/addons/mod/data/services/data-helper.ts | 4 ++-- .../mod/data/tests/behat/entries.feature | 19 +++++++++++++++++++ 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/addons/mod/data/fields/number/services/handler.ts b/src/addons/mod/data/fields/number/services/handler.ts index 05ea5dd74a4..5846662c448 100644 --- a/src/addons/mod/data/fields/number/services/handler.ts +++ b/src/addons/mod/data/fields/number/services/handler.ts @@ -45,17 +45,17 @@ export class AddonModDataFieldNumberHandlerService extends AddonModDataFieldText originalFieldData: AddonModDataEntryField, ): boolean { const fieldName = 'f_' + field.id; - const input = inputData[fieldName] || ''; - const content = originalFieldData?.content || ''; + const input = inputData[fieldName] ?? ''; + const content = originalFieldData?.content ?? ''; - return input != content; + return input !== content; } /** * @inheritdoc */ getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { - if (field.required && (!inputData || !inputData.length || inputData[0].value == '')) { + if (field.required && (!inputData || !inputData.length || inputData[0].value === '')) { return Translate.instant('addon.mod_data.errormustsupplyvalue'); } } diff --git a/src/addons/mod/data/fields/text/services/handler.ts b/src/addons/mod/data/fields/text/services/handler.ts index 8083b72b8bd..40088d2304e 100644 --- a/src/addons/mod/data/fields/text/services/handler.ts +++ b/src/addons/mod/data/fields/text/services/handler.ts @@ -70,7 +70,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan return [{ fieldid: field.id, - value: inputData[fieldName] || '', + value: inputData[fieldName] ?? '', }]; } @@ -83,10 +83,10 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan originalFieldData: AddonModDataEntryField, ): boolean { const fieldName = 'f_' + field.id; - const input = inputData[fieldName] || ''; - const content = originalFieldData?.content || ''; + const input = inputData[fieldName] ?? ''; + const content = originalFieldData?.content ?? ''; - return input != content; + return input !== content; } /** @@ -102,7 +102,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan * @inheritdoc */ overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { - originalContent.content = offlineContent[''] || ''; + originalContent.content = offlineContent[''] ?? ''; return originalContent; } diff --git a/src/addons/mod/data/pages/edit/edit.ts b/src/addons/mod/data/pages/edit/edit.ts index e95ec4a5e12..6509bc4a4e4 100644 --- a/src/addons/mod/data/pages/edit/edit.ts +++ b/src/addons/mod/data/pages/edit/edit.ts @@ -42,6 +42,7 @@ import { import { AddonModDataHelper } from '../../services/data-helper'; import { CoreDom } from '@singletons/dom'; import { AddonModDataEntryFieldInitialized } from '../../classes/base-field-plugin-component'; +import { CoreTextUtils } from '@services/utils/text'; /** * Page that displays the view edit page. @@ -368,9 +369,18 @@ export class AddonModDataEditPage implements OnInit { } }); } + this.jsData!.errors = this.errors; this.scrollToFirstError(); + + if (updateEntryResult.generalnotifications?.length) { + CoreDomUtils.showAlertWithOptions({ + header: Translate.instant('core.notice'), + message: CoreTextUtils.buildMessage(updateEntryResult.generalnotifications), + buttons: [Translate.instant('core.ok')], + }); + } } } finally { modal.dismiss(); diff --git a/src/addons/mod/data/services/data-helper.ts b/src/addons/mod/data/services/data-helper.ts index c83abf47aa2..52b822c955e 100644 --- a/src/addons/mod/data/services/data-helper.ts +++ b/src/addons/mod/data/services/data-helper.ts @@ -590,8 +590,8 @@ export class AddonModDataHelperProvider { // WS wants values in JSON format. entryFieldDataToSend.push({ fieldid: fieldSubdata.fieldid, - subfield: fieldSubdata.subfield || '', - value: value ? JSON.stringify(value) : '', + subfield: fieldSubdata.subfield ?? '', + value: (value || value === 0) ? JSON.stringify(value) : '', }); return; diff --git a/src/addons/mod/data/tests/behat/entries.feature b/src/addons/mod/data/tests/behat/entries.feature index 59320cca381..bd6707cdfed 100644 --- a/src/addons/mod/data/tests/behat/entries.feature +++ b/src/addons/mod/data/tests/behat/entries.feature @@ -206,3 +206,22 @@ Feature: Users can manage entries in database activities Then I should find "Are you sure you want to delete this entry?" in the app And I press "Delete" in the app And I should not find "Moodle Cloud" in the app + + Scenario: Handle number 0 correctly when creating entries + Given the following "activities" exist: + | activity | name | intro | course | idnumber | + | data | Number DB | Number DB | C1 | data2 | + And the following "mod_data > fields" exist: + | database | type | name | description | + | data2 | number | Number | Number value | + And I entered the data activity "Number DB" on course "Course 1" as "student1" in the app + When I press "Add entries" in the app + And I press "Save" near "Number DB" in the app + Then I should find "You did not fill out any fields!" in the app + + When I press "OK" in the app + And I set the following fields to these values in the app: + | Number | 0 | + And I press "Save" near "Number DB" in the app + Then I should find "0" near "Number:" in the app + But I should not find "Save" in the app From 8e568b83b5cbda45add16c4a086a554622001be0 Mon Sep 17 00:00:00 2001 From: Juan Leyva Date: Thu, 26 Jan 2023 17:44:55 +0100 Subject: [PATCH 09/80] MOBILE-4239 release: Update readme --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae0e4be9f16..1a9f4aa4c24 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ Moodle App This is the primary repository of source code for the official mobile app for Moodle. * [User documentation](https://docs.moodle.org/en/Moodle_app) -* [Developer documentation](http://docs.moodle.org/dev/Moodle_App) -* [Development environment setup](https://docs.moodle.org/dev/Setting_up_your_development_environment_for_the_Moodle_App) +* [Developer documentation](https://moodledev.io/general/app) +* [Development environment setup](https://moodledev.io/general/app/development/setup) * [Bug Tracker](https://tracker.moodle.org/browse/MOBILE) -* [Release Notes](https://docs.moodle.org/dev/Moodle_App_Release_Notes) +* [Release Notes](https://moodledev.io/general/app_releases) + +This project is tested with BrowserStack. License ------- From 14f48f374d3d8b24877bfde16c880c8a435b4887 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 31 Jan 2023 12:48:20 +0100 Subject: [PATCH 10/80] MOBILE-4077 ReportBuilder: Add translations --- scripts/langindex.json | 6 ++++++ src/core/features/reportbuilder/lang.json | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 src/core/features/reportbuilder/lang.json diff --git a/scripts/langindex.json b/scripts/langindex.json index 7d24079128b..325e56d4209 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1652,6 +1652,12 @@ "core.courses.totalcoursesearchresults": "local_moodlemobileapp", "core.currentdevice": "local_moodlemobileapp", "core.custom": "form", + "core.reportbuilder.modifiedby": "tool_reportbuilder", + "core.reportbuilder.reportstab": "tool_reportbuilder", + "core.reportbuilder.reportsource": "tool_reportbuilder", + "core.reportbuilder.timecreated": "tool_reportbuilder", + "core.reportbuilder.showcolumns": "local_moodlemobileapp", + "core.reportbuilder.hidecolumns": "local_moodlemobileapp", "core.datastoredoffline": "local_moodlemobileapp", "core.date": "moodle", "core.datecreated": "repository", diff --git a/src/core/features/reportbuilder/lang.json b/src/core/features/reportbuilder/lang.json new file mode 100644 index 00000000000..299a7a10fc7 --- /dev/null +++ b/src/core/features/reportbuilder/lang.json @@ -0,0 +1,8 @@ +{ + "modifiedby": "Modified by", + "reportstab": "Reports", + "reportsource": "Report source", + "timecreated": "Time created", + "showcolumns": "Show columns", + "hidecolumns": "Hide columns" +} From 6f869d145344470fa1e2057c3b0ccefbd9a8a449 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 31 Jan 2023 12:49:45 +0100 Subject: [PATCH 11/80] MOBILE-4077 ReportBuilder: Create handler and service --- .../reportbuilder/classes/reports-source.ts | 55 ++++ .../services/handlers/reportbuilder.ts | 59 ++++ .../reportbuilder/services/reportbuilder.ts | 265 ++++++++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 src/core/features/reportbuilder/classes/reports-source.ts create mode 100644 src/core/features/reportbuilder/services/handlers/reportbuilder.ts create mode 100644 src/core/features/reportbuilder/services/reportbuilder.ts diff --git a/src/core/features/reportbuilder/classes/reports-source.ts b/src/core/features/reportbuilder/classes/reports-source.ts new file mode 100644 index 00000000000..31449689f3d --- /dev/null +++ b/src/core/features/reportbuilder/classes/reports-source.ts @@ -0,0 +1,55 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; +import { CoreReportBuilder, CoreReportBuilderReport, REPORTS_LIST_LIMIT } from '../services/reportbuilder'; + +/** + * Provides a list of reports. + */ +export class CoreReportBuilderReportsSource extends CoreRoutedItemsManagerSource { + + /** + * @inheritdoc + */ + getItemPath(report: CoreReportBuilderReport): string { + return report.id.toString(); + } + + /** + * @inheritdoc + */ + protected async loadPageItems(page: number): Promise<{ items: CoreReportBuilderReport[]; hasMoreItems: boolean }> { + const reports = await CoreReportBuilder.getReports(page, this.getPageLength()); + + return { items: reports, hasMoreItems: reports.length > 0 }; + } + + /** + * @inheritdoc + */ + protected setItems(reports: CoreReportBuilderReport[], hasMoreItems: boolean): void { + const sortedReports = reports.slice(0); + reports.sort((a, b) => a.timecreated < b.timecreated ? 1 : -1); + super.setItems(sortedReports, hasMoreItems); + } + + /** + * @inheritdoc + */ + protected getPageLength(): number { + return REPORTS_LIST_LIMIT; + } + +} diff --git a/src/core/features/reportbuilder/services/handlers/reportbuilder.ts b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts new file mode 100644 index 00000000000..4a463f46e2c --- /dev/null +++ b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts @@ -0,0 +1,59 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { CoreReportBuilder } from '../reportbuilder'; + +/** + * Handler to visualize custom reports. + */ +@Injectable({ providedIn: 'root' }) +export class CoreReportBuilderHandlerService implements CoreUserProfileHandler { + + static readonly PAGE_NAME = 'reportbuilder'; + + type = CoreUserDelegateService.TYPE_NEW_PAGE; + cacheEnabled = true; + name = 'CoreReportBuilderDelegate'; + priority = 350; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return await CoreReportBuilder.isEnabled(); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + class: 'core-report-builder', + icon: 'fa-list-alt', + title: 'core.reportbuilder.reportstab', + action: async (event): Promise => { + event.preventDefault(); + event.stopPropagation(); + await CoreNavigator.navigate(`/${CoreReportBuilderHandlerService.PAGE_NAME}`); + }, + }; + } + +} + +export const CoreReportBuilderHandler = makeSingleton(CoreReportBuilderHandlerService); diff --git a/src/core/features/reportbuilder/services/reportbuilder.ts b/src/core/features/reportbuilder/services/reportbuilder.ts new file mode 100644 index 00000000000..2564e25cc58 --- /dev/null +++ b/src/core/features/reportbuilder/services/reportbuilder.ts @@ -0,0 +1,265 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// (C) Copyright 2015 Moodle Pty Ltd. +// + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSites } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; + +const ROOT_CACHE_KEY = 'mmaReportBuilder:'; +export const REPORTS_LIST_LIMIT = 20; +export const REPORT_ROWS_LIMIT = 20; + +@Injectable({ providedIn: 'root' }) +export class CoreReportBuilderService { + + /** + * Obtain the reports list. + * + * @param page Current page. + * @param perpage Reports obtained per page. + * @returns Reports list. + */ + async getReports(page?: number, perpage?: number): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderCacheKey() }; + const response = await site.read( + 'core_reportbuilder_list_reports', + { page, perpage }, + preSets, + ); + + return response.reports; + } + + /** + * Get the detail of a report. + * + * @param reportid Report id + * @param page Current page. + * @param perpage Rows obtained per page. + * @returns Detail of the report. + */ + async loadReport(reportid: number, page?: number, perpage?: number): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderReportCacheKey() }; + const report = await site.read( + 'core_reportbuilder_retrieve_report', + { reportid, page, perpage }, + preSets, + ); + + if (!report) { + throw new CoreError('An error ocurred.'); + } + + const settingsData: { + // eslint-disable-next-line @typescript-eslint/naming-convention + cardview_showfirsttitle: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + cardview_visiblecolumns: number; + } = report.details.settingsdata ? JSON.parse(report.details.settingsdata) : {}; + + const mappedSettingsData: CoreReportBuilderReportDetailSettingsData = { + cardviewShowFirstTitle: settingsData.cardview_showfirsttitle === 1, + cardviewVisibleColumns: settingsData.cardview_visiblecolumns ?? 1, + }; + + return { + ...report, + details: { + ...report.details, + settingsdata: mappedSettingsData, + }, + data: { + ...report.data, + rows: [...report.data.rows.map(row => ({ columns: row.columns, isExpanded: row.isExpanded ?? false }))], + }, + }; + } + + /** + * View a report. + * + * @param reportid Report viewed. + * @returns Response of the WS. + */ + async viewReport(reportid: string): Promise { + const site = CoreSites.getRequiredCurrentSite(); + + await site.write('core_reportbuilder_view_report', { reportid }); + } + + /** + * Check if the feature is enabled or disabled. + * + * @returns Feature enabled or disabled. + */ + async isEnabled(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const hasTheVersionRequired = site.isVersionGreaterEqualThan('4.1'); + const hasAdvancedFeatureEnabled = site.canUseAdvancedFeature('enablecustomreports'); + const isFeatureDisabled = site.isFeatureDisabled('CoreReportBuilderDelegate'); + + return hasTheVersionRequired && hasAdvancedFeatureEnabled && !isFeatureDisabled; + } + + /** + * Invalidates reports list WS calls. + * + * @returns Promise resolved when the list is invalidated. + */ + async invalidateReportsList(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + await site.invalidateWsCacheForKey(this.getReportBuilderCacheKey()); + } + + /** + * Invalidates report WS calls. + * + * @returns Promise resolved when report is invalidated. + */ + async invalidateReport(): Promise { + const site = CoreSites.getCurrentSite(); + + if (!site) { + return; + } + + await site.invalidateWsCacheForKey(this.getReportBuilderReportCacheKey()); + } + + /** + * Get cache key for report builder list WS calls. + * + * @returns Cache key. + */ + protected getReportBuilderCacheKey(): string { + return ROOT_CACHE_KEY + 'list'; + } + + /** + * Get cache key for report builder report WS calls. + * + * @returns Cache key. + */ + protected getReportBuilderReportCacheKey(): string { + return ROOT_CACHE_KEY + 'report'; + } + +} + +export const CoreReportBuilder = makeSingleton(CoreReportBuilderService); + +type CoreReportBuilderPagination = { + page?: number; + perpage?: number; +}; + +export type CoreReportBuilderRetrieveReportWSParams = CoreReportBuilderPagination & { + reportid: number; // Report ID. +}; + +/** + * Data returned by core_reportbuilder_list_reports WS. + */ +export type CoreReportBuilderListReportsWSResponse = { + reports: CoreReportBuilderReportWSResponse[]; + warnings?: CoreWSExternalWarning[]; +}; + +export type CoreReportBuilderReportWSResponse = { + name: string; // Name. + source: string; // Source. + type: number; // Type. + uniquerows: boolean; // Uniquerows. + conditiondata: string; // Conditiondata. + settingsdata: string | null; // Settingsdata. + contextid: number; // Contextid. + component: string; // Component. + area: string; // Area. + itemid: number; // Itemid. + usercreated: number; // Usercreated. + id: number; // Id. + timecreated: number; // Timecreated. + timemodified: number; // Timemodified. + usermodified: number; // Usermodified. + sourcename: string; // Sourcename. + modifiedby: { + id: number; // Id. + email: string; // Email. + idnumber: string; // Idnumber. + phone1: string; // Phone1. + phone2: string; // Phone2. + department: string; // Department. + institution: string; // Institution. + fullname: string; // Fullname. + identity: string; // Identity. + profileurl: string; // Profileurl. + profileimageurl: string; // Profileimageurl. + profileimageurlsmall: string; // Profileimageurlsmall. + }; +}; + +/** + * Data returned by core_reportbuilder_retrieve_report WS. + */ +export type CoreReportBuilderRetrieveReportWSResponse = { + details: CoreReportBuilderReportWSResponse; + data: CoreReportBuilderReportDataWSResponse; + warnings?: CoreWSExternalWarning[]; +}; + +export interface CoreReportBuilderRetrieveReportMapped extends Omit { + details: CoreReportBuilderReportDetail; +} + +export type CoreReportBuilderReportDataWSResponse = { + headers: string[]; // Headers. + rows: { // Rows. + columns: string[]; // Columns. + isExpanded: boolean; + }[]; + totalrowcount: number; // Totalrowcount. +}; + +/** + * Params of core_reportbuilder_view_report WS. + */ +export type CoreReportBuilderViewReportWSParams = { + reportid: number; // Report ID. +}; + +/** + * Data returned by core_reportbuilder_view_report WS. + */ +export type CoreReportBuilderViewReportWSResponse = { + status: boolean; // Success. + warnings?: CoreWSExternalWarning[]; +}; + +export interface CoreReportBuilderReportDetail extends Omit { + settingsdata: CoreReportBuilderReportDetailSettingsData; +} + +export type CoreReportBuilderReportDetailSettingsData = { + cardviewShowFirstTitle: boolean; + cardviewVisibleColumns: number; +}; + +export interface CoreReportBuilderReport extends CoreReportBuilderReportWSResponse {}; From cb2d17af9bdeb8782848c022b8554a840b7cdbc1 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 31 Jan 2023 12:50:45 +0100 Subject: [PATCH 12/80] MOBILE-4077 ReportBuilder: Create page and module --- src/core/features/features.module.ts | 2 + .../components/components.module.ts | 36 ++++ .../report-column/report-column.html | 11 + .../report-column/report-column.scss | 11 + .../components/report-column/report-column.ts | 41 ++++ .../report-detail/report-detail.html | 60 ++++++ .../report-detail/report-detail.scss | 44 ++++ .../components/report-detail/report-detail.ts | 201 ++++++++++++++++++ .../report-summary/report-summary.html | 39 ++++ .../report-summary/report-summary.ts | 60 ++++++ .../reportbuilder/pages/list/list.html | 35 +++ .../features/reportbuilder/pages/list/list.ts | 128 +++++++++++ .../reportbuilder/pages/report/report.html | 21 ++ .../reportbuilder/pages/report/report.ts | 52 +++++ .../reportbuilder-lazy.module.ts | 44 ++++ .../reportbuilder/reportbuilder.module.ts | 39 ++++ 16 files changed, 824 insertions(+) create mode 100644 src/core/features/reportbuilder/components/components.module.ts create mode 100644 src/core/features/reportbuilder/components/report-column/report-column.html create mode 100644 src/core/features/reportbuilder/components/report-column/report-column.scss create mode 100644 src/core/features/reportbuilder/components/report-column/report-column.ts create mode 100644 src/core/features/reportbuilder/components/report-detail/report-detail.html create mode 100644 src/core/features/reportbuilder/components/report-detail/report-detail.scss create mode 100644 src/core/features/reportbuilder/components/report-detail/report-detail.ts create mode 100644 src/core/features/reportbuilder/components/report-summary/report-summary.html create mode 100644 src/core/features/reportbuilder/components/report-summary/report-summary.ts create mode 100644 src/core/features/reportbuilder/pages/list/list.html create mode 100644 src/core/features/reportbuilder/pages/list/list.ts create mode 100644 src/core/features/reportbuilder/pages/report/report.html create mode 100644 src/core/features/reportbuilder/pages/report/report.ts create mode 100644 src/core/features/reportbuilder/reportbuilder-lazy.module.ts create mode 100644 src/core/features/reportbuilder/reportbuilder.module.ts diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 7b5d7e56908..6c4e3240e58 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -43,6 +43,7 @@ import { CoreUserModule } from './user/user.module'; import { CoreUserToursModule } from './usertours/user-tours.module'; import { CoreViewerModule } from './viewer/viewer.module'; import { CoreXAPIModule } from './xapi/xapi.module'; +import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module'; @NgModule({ imports: [ @@ -74,6 +75,7 @@ import { CoreXAPIModule } from './xapi/xapi.module'; CoreUserToursModule, CoreViewerModule, CoreXAPIModule, + CoreReportBuilderModule, // Import last to allow overrides. CoreEmulatorModule, diff --git a/src/core/features/reportbuilder/components/components.module.ts b/src/core/features/reportbuilder/components/components.module.ts new file mode 100644 index 00000000000..f8f269fc395 --- /dev/null +++ b/src/core/features/reportbuilder/components/components.module.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreReportBuilderReportColumnComponent } from './report-column/report-column'; +import { CoreReportBuilderReportDetailComponent } from './report-detail/report-detail'; +import { CoreReportBuilderReportSummaryComponent } from './report-summary/report-summary'; + +@NgModule({ + imports: [ + CoreSharedModule, + ], + declarations: [ + CoreReportBuilderReportDetailComponent, + CoreReportBuilderReportColumnComponent, + CoreReportBuilderReportSummaryComponent, + ], + exports: [ + CoreReportBuilderReportDetailComponent, + CoreReportBuilderReportColumnComponent, + CoreReportBuilderReportSummaryComponent, + ], +}) +export class CoreReportBuilderComponentsModule {} diff --git a/src/core/features/reportbuilder/components/report-column/report-column.html b/src/core/features/reportbuilder/components/report-column/report-column.html new file mode 100644 index 00000000000..c5ee3a6ff79 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.html @@ -0,0 +1,11 @@ + + +

{{ header }}

+ + + + diff --git a/src/core/features/reportbuilder/components/report-column/report-column.scss b/src/core/features/reportbuilder/components/report-column/report-column.scss new file mode 100644 index 00000000000..65df0f95372 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.scss @@ -0,0 +1,11 @@ +@import "~theme/globals"; + +:host { + --rotate-expandable: rotate(180deg); + + .expandable-status-icon { + font-size: var(--text-size); + @include margin-horizontal(0, 2px); + @include core-transition(transform, 200ms); + } +} diff --git a/src/core/features/reportbuilder/components/report-column/report-column.ts b/src/core/features/reportbuilder/components/report-column/report-column.ts new file mode 100644 index 00000000000..cf5575c3d42 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'core-report-builder-report-column', + templateUrl: './report-column.html', + styleUrls: ['./report-column.scss'], +}) +export class CoreReportBuilderReportColumnComponent { + + @Input() isExpanded = false; + @Input() isExpandable = false; + @Input() showFirstTitle = false; + @Input() columnIndex!: number; + @Input() rowIndex!: number; + @Input() column!: string; + @Input() contextId!: number; + @Input() header!: string; + @Output() onToggleRow: EventEmitter = new EventEmitter(); + + /** + * Emits row click + */ + toggleRow(): void { + this.onToggleRow.emit(this.rowIndex); + } + +} diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.html b/src/core/features/reportbuilder/components/report-detail/report-detail.html new file mode 100644 index 00000000000..13e856f82d2 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ header }} +
+ + +
+
+ +
+ + + + + + + + + +
+ +
diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.scss b/src/core/features/reportbuilder/components/report-detail/report-detail.scss new file mode 100644 index 00000000000..9851fcad6cd --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.scss @@ -0,0 +1,44 @@ +@import "~theme/globals"; + +:host { + --header-background: var(--white); + --border-color: var(--stroke); + + .report-title { + ion-item { + width: 100%; + } + } + + table { + width: 98%; + margin: 1em auto; + border-collapse: collapse; + color: var(--ion-text-color); + overflow-x: auto; + display: block; + + tbody { + display: table; + } + + th { + background-color: var(--header-background); + } + + tr { + border-bottom: 1px solid var(--border-color); + + &:nth-child(even) { + background: var(--light); + } + } + + th, td { + @include padding(8px, 8px, 8px, null); + text-align: start; + min-width: 200px; + } + + } +} diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.ts b/src/core/features/reportbuilder/components/report-detail/report-detail.ts new file mode 100644 index 00000000000..9922771027e --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.ts @@ -0,0 +1,201 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { + CoreReportBuilder, + CoreReportBuilderReportDetail, + CoreReportBuilderRetrieveReportMapped, + REPORT_ROWS_LIMIT, +} from '@features/reportbuilder/services/reportbuilder'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'core-report-builder-report-detail', + templateUrl: './report-detail.html', + styleUrls: ['./report-detail.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreReportBuilderReportDetailComponent implements OnInit { + + @Input() reportId!: string; + @Input() isBlock = true; + @Input() perPage?: number; + @Input() layout: 'card' | 'table' | 'adaptative' = 'adaptative'; + @Output() onReportLoaded = new EventEmitter(); + + get isCardLayout(): boolean { + return this.layout === 'card' || (CoreScreen.isMobile && this.layout === 'adaptative'); + } + + state$: Readonly> = new BehaviorSubject({ + report: null, + loaded: false, + canLoadMoreRows: true, + errorLoadingRows: false, + cardviewShowFirstTitle: false, + cardVisibleColumns: 1, + page: 0, + }); + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + await this.getReport(); + this.updateState({ loaded: true }); + } + + /** + * Get report data. + */ + async getReport(): Promise { + if (!this.reportId) { + CoreDomUtils.showErrorModal(new CoreError('No report found')); + CoreNavigator.back(); + + return; + } + + const { page } = this.state$.getValue(); + + const report = await CoreReportBuilder.loadReport(parseInt(this.reportId), page,this.perPage ?? REPORT_ROWS_LIMIT); + + if (!report) { + CoreDomUtils.showErrorModal(new CoreError('No report found')); + CoreNavigator.back(); + + return; + } + + await CoreReportBuilder.viewReport(this.reportId); + + this.updateState({ + report, + cardVisibleColumns: report.details.settingsdata.cardviewVisibleColumns, + cardviewShowFirstTitle: report.details.settingsdata.cardviewShowFirstTitle, + }); + + this.onReportLoaded.emit(report.details); + } + + updateState(state: Partial): void { + const previousState = this.state$.getValue(); + this.state$.next({ ...previousState, ...state }); + } + + /** + * Update report data. + * + * @param ionRefresher ionic refresher. + */ + async refreshReport(ionRefresher?: IonRefresher): Promise { + await CoreUtils.ignoreErrors(CoreReportBuilder.invalidateReport()); + this.updateState({ page: 0, canLoadMoreRows: false }); + await CoreUtils.ignoreErrors(this.getReport()); + await ionRefresher?.complete(); + this.updateState({ canLoadMoreRows: true }); + } + + /** + * Increment page of report rows. + */ + protected incrementPage(): void { + const { page } = this.state$.getValue(); + this.updateState({ page: page + 1 }); + } + + /** + * Load a new batch of pages. + * + * @param complete Completion callback. + */ + async fetchMoreInfo(complete: () => void): Promise { + const { canLoadMoreRows, report } = this.state$.getValue(); + + if (!canLoadMoreRows) { + complete(); + + return; + } + + try { + this.incrementPage(); + + const { page: currentPage } = this.state$.getValue(); + + const newReport = await CoreReportBuilder.loadReport(parseInt(this.reportId), currentPage, REPORT_ROWS_LIMIT); + + if (!report || !newReport || newReport.data.rows.length === 0) { + this.updateState({ canLoadMoreRows: false }); + complete(); + + return; + } + + this.updateState({ + report: { + ...report, + data: { + ...report.data, + rows: [ + ...report.data.rows, + ...newReport.data.rows, + ], + }, + }, + }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading more reports'); + + this.updateState({ canLoadMoreRows: false }); + this.updateState({ errorLoadingRows: true }); + } + + complete(); + } + + /** + * Expand or close card. + * + * @param rowIndex card to expand or close. + */ + toggleRow(rowIndex: number): void { + const { report } = this.state$.getValue(); + + if (!report?.data?.rows[rowIndex]) { + return; + } + + report.data.rows[rowIndex].isExpanded = !report.data.rows[rowIndex].isExpanded; + this.updateState({ report }); + } + +} + +export type CoreReportBuilderReportDetailState = { + report: CoreReportBuilderRetrieveReportMapped | null; + loaded: boolean; + canLoadMoreRows: boolean; + errorLoadingRows: boolean; + cardviewShowFirstTitle: boolean; + cardVisibleColumns: number; + page: number; +}; diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.html b/src/core/features/reportbuilder/components/report-summary/report-summary.html new file mode 100644 index 00000000000..7c37437c4b2 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + +
+ + +

+ + +

+
+ + + +
+ + + +

{{ item.title | translate }}

+ + +
+
+
+
diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.ts b/src/core/features/reportbuilder/components/report-summary/report-summary.ts new file mode 100644 index 00000000000..712914e3afb --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.ts @@ -0,0 +1,60 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { CoreReportBuilderReportDetail } from '@features/reportbuilder/services/reportbuilder'; +import { CoreFormatDatePipe } from '@pipes/format-date'; +import { CoreSites } from '@services/sites'; +import { ModalController } from '@singletons'; + +@Component({ + selector: 'core-report-builder-report-summary', + templateUrl: './report-summary.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreReportBuilderReportSummaryComponent implements OnInit { + + @Input() reportDetail!: CoreReportBuilderReportDetail; + reportUrl!: string; + reportDetailToDisplay!: { title: string; text: string }[]; + + ngOnInit(): void { + const formatDate = new CoreFormatDatePipe(); + const site = CoreSites.getRequiredCurrentSite(); + this.reportUrl = `${site.getURL()}/reportbuilder/view.php?id=${this.reportDetail.id}`; + this.reportDetailToDisplay = [ + { + title: 'core.reportbuilder.reportsource', + text: this.reportDetail.sourcename, + }, + { + title: 'core.reportbuilder.timecreated', + text: formatDate.transform(this.reportDetail.timecreated * 1000), + }, + { + title: 'addon.mod_data.timemodified', + text: formatDate.transform(this.reportDetail.timemodified * 1000), + }, + { + title: 'core.reportbuilder.modifiedby', + text: this.reportDetail.modifiedby.fullname, + }, + ]; + } + + closeModal(): void { + ModalController.dismiss(); + } + +} diff --git a/src/core/features/reportbuilder/pages/list/list.html b/src/core/features/reportbuilder/pages/list/list.html new file mode 100644 index 00000000000..832fc49d572 --- /dev/null +++ b/src/core/features/reportbuilder/pages/list/list.html @@ -0,0 +1,35 @@ + + + + + + +

{{ 'core.reportbuilder.reportstab' | translate }}

+
+
+
+ + + + + + + + +

{{ report.name }}

+

{{ report.sourcename }}

+
+
+
+ + + + + + + + +
+
diff --git a/src/core/features/reportbuilder/pages/list/list.ts b/src/core/features/reportbuilder/pages/list/list.ts new file mode 100644 index 00000000000..2538cb26010 --- /dev/null +++ b/src/core/features/reportbuilder/pages/list/list.ts @@ -0,0 +1,128 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; +import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CoreReportBuilderReportsSource } from '@features/reportbuilder/classes/reports-source'; +import { CoreReportBuilder, CoreReportBuilderReport, REPORTS_LIST_LIMIT } from '@features/reportbuilder/services/reportbuilder'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'core-report-builder-list', + templateUrl: './list.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreReportBuilderListPage implements AfterViewInit, OnDestroy { + + reports!: CoreListItemsManager; + + state$: Readonly> = new BehaviorSubject({ + page: 1, + perpage: REPORTS_LIST_LIMIT, + loaded: false, + loadMoreError: false, + }); + + constructor() { + try { + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreReportBuilderReportsSource, []); + this.reports = new CoreListItemsManager(source, CoreReportBuilderListPage); + } catch (error) { + CoreDomUtils.showErrorModal(error); + CoreNavigator.back(); + } + } + + /** + * @inheritdoc + */ + async ngAfterViewInit(): Promise { + try { + await this.fetchReports(true); + this.updateState({ loaded: true }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading reports'); + + this.reports.reset(); + } + } + + /** + * Update reports list or loads it. + * + * @param reload is reoading or not. + */ + async fetchReports(reload: boolean): Promise { + reload ? await this.reports.reload() : await this.reports.load(); + this.updateState({ loadMoreError: false }); + } + + /** + * Properties of the state to update. + * + * @param state Object to update. + */ + updateState(state: Partial): void { + const previousState = this.state$.getValue(); + this.state$.next({ ...previousState, ...state }); + } + + /** + * Load a new batch of Reports. + * + * @param complete Completion callback. + */ + async fetchMoreReports(complete: () => void): Promise { + try { + await this.fetchReports(false); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading more reports'); + + this.updateState({ loadMoreError: true }); + } + + complete(); + } + + /** + * Refresh reports list. + * + * @param ionRefresher ionRefresher. + */ + async refreshReports(ionRefresher?: IonRefresher): Promise { + await CoreUtils.ignoreErrors(CoreReportBuilder.invalidateReportsList()); + await CoreUtils.ignoreErrors(this.fetchReports(true)); + await ionRefresher?.complete(); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.reports.destroy(); + } + +} + +type CoreReportBuilderListState = { + page: number; + perpage: number; + loaded: boolean; + loadMoreError: boolean; +}; diff --git a/src/core/features/reportbuilder/pages/report/report.html b/src/core/features/reportbuilder/pages/report/report.html new file mode 100644 index 00000000000..4dd499a7041 --- /dev/null +++ b/src/core/features/reportbuilder/pages/report/report.html @@ -0,0 +1,21 @@ + + + + + + + + + + + +

{{ reportDetail.name }}

+

{{ reportDetail.sourcename }}

+
+
+
+ + + + + diff --git a/src/core/features/reportbuilder/pages/report/report.ts b/src/core/features/reportbuilder/pages/report/report.ts new file mode 100644 index 00000000000..958509675c9 --- /dev/null +++ b/src/core/features/reportbuilder/pages/report/report.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { CoreReportBuilderReportSummaryComponent } from '@features/reportbuilder/components/report-summary/report-summary'; +import { CoreReportBuilderReportDetail } from '@features/reportbuilder/services/reportbuilder'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; + +@Component({ + selector: 'core-report-builder-report', + templateUrl: './report.html', +}) +export class CoreReportBuilderReportPage implements OnInit { + + reportId!: string; + reportDetail?: CoreReportBuilderReportDetail; + /** + * @inheritdoc + */ + ngOnInit(): void { + this.reportId = CoreNavigator.getRequiredRouteParam('id'); + } + + /** + * Save the report detail + * + * @param reportDetail it contents the detail of the report. + */ + loadReportDetail(reportDetail: CoreReportBuilderReportDetail): void { + this.reportDetail = reportDetail; + } + + openInfo(): void { + CoreDomUtils.openSideModal({ + component: CoreReportBuilderReportSummaryComponent, + componentProps: { reportDetail: this.reportDetail }, + }); + } + +} diff --git a/src/core/features/reportbuilder/reportbuilder-lazy.module.ts b/src/core/features/reportbuilder/reportbuilder-lazy.module.ts new file mode 100644 index 00000000000..c5064d38860 --- /dev/null +++ b/src/core/features/reportbuilder/reportbuilder-lazy.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoreReportBuilderComponentsModule } from './components/components.module'; +import { CoreReportBuilderListPage } from './pages/list/list'; +import { CoreReportBuilderReportPage } from './pages/report/report'; + +const routes: Routes = [ + { + path: '', + component: CoreReportBuilderListPage, + }, + { + path: ':id', + component: CoreReportBuilderReportPage, + }, +]; + +@NgModule({ + imports: [ + CoreSharedModule, + CoreReportBuilderComponentsModule, + RouterModule.forChild(routes), + ], + declarations: [ + CoreReportBuilderListPage, + CoreReportBuilderReportPage, + ], +}) +export class CoreReportBuilderLazyModule {} diff --git a/src/core/features/reportbuilder/reportbuilder.module.ts b/src/core/features/reportbuilder/reportbuilder.module.ts new file mode 100644 index 00000000000..833c8df1fed --- /dev/null +++ b/src/core/features/reportbuilder/reportbuilder.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { CoreReportBuilderHandler, CoreReportBuilderHandlerService } from './services/handlers/reportbuilder'; + +const routes: Routes = [ + { + path: CoreReportBuilderHandlerService.PAGE_NAME, + loadChildren: () => import('./reportbuilder-lazy.module').then(m => m.CoreReportBuilderLazyModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreUserDelegate.registerHandler(CoreReportBuilderHandler.instance); + }, + }, + ], +}) +export class CoreReportBuilderModule {} From 7b37a907a9aad25f019895bb906561478b441d1c Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 31 Jan 2023 12:51:00 +0100 Subject: [PATCH 13/80] MOBILE-4077 ReportBuilder: Create tests --- .../tests/behat/reportbuilder.feature | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/core/features/reportbuilder/tests/behat/reportbuilder.feature diff --git a/src/core/features/reportbuilder/tests/behat/reportbuilder.feature b/src/core/features/reportbuilder/tests/behat/reportbuilder.feature new file mode 100644 index 00000000000..ab128c38fec --- /dev/null +++ b/src/core/features/reportbuilder/tests/behat/reportbuilder.feature @@ -0,0 +1,145 @@ +@app @javascript @core_reportbuilder +Feature: Report builder + + Background: + Given the following "core_reportbuilder > Reports" exist: + | name | source | default | + | My report 01 | core_user\reportbuilder\datasource\users | 1 | + | My report 02 | core_user\reportbuilder\datasource\users | 2 | + | My report 03 | core_user\reportbuilder\datasource\users | 3 | + | My report 04 | core_user\reportbuilder\datasource\users | 4 | + | My report 05 | core_user\reportbuilder\datasource\users | 5 | + | My report 06 | core_user\reportbuilder\datasource\users | 6 | + | My report 07 | core_user\reportbuilder\datasource\users | 7 | + | My report 08 | core_user\reportbuilder\datasource\users | 8 | + | My report 09 | core_user\reportbuilder\datasource\users | 9 | + | My report 10 | core_user\reportbuilder\datasource\users | 10 | + | My report 11 | core_user\reportbuilder\datasource\users | 11 | + | My report 12 | core_user\reportbuilder\datasource\users | 12 | + | My report 13 | core_user\reportbuilder\datasource\users | 13 | + | My report 14 | core_user\reportbuilder\datasource\users | 14 | + | My report 15 | core_user\reportbuilder\datasource\users | 15 | + | My report 16 | core_user\reportbuilder\datasource\users | 16 | + | My report 17 | core_user\reportbuilder\datasource\users | 17 | + | My report 18 | core_user\reportbuilder\datasource\users | 18 | + | My report 19 | core_user\reportbuilder\datasource\users | 19 | + | My report 20 | core_user\reportbuilder\datasource\users | 20 | + | My report 21 | core_user\reportbuilder\datasource\users | 21 | + | My report 22 | core_user\reportbuilder\datasource\users | 22 | + | My report 23 | core_user\reportbuilder\datasource\users | 23 | + | My report 24 | core_user\reportbuilder\datasource\users | 24 | + | My report 25 | core_user\reportbuilder\datasource\users | 25 | + | My report 26 | core_user\reportbuilder\datasource\users | 26 | + | My report 27 | core_user\reportbuilder\datasource\users | 27 | + | My report 28 | core_user\reportbuilder\datasource\users | 28 | + | My report 29 | core_user\reportbuilder\datasource\users | 29 | + | My report 30 | core_user\reportbuilder\datasource\users | 30 | + | My report 31 | core_user\reportbuilder\datasource\users | 31 | + | My report 32 | core_user\reportbuilder\datasource\users | 32 | + | My report 33 | core_user\reportbuilder\datasource\users | 33 | + | My report 34 | core_user\reportbuilder\datasource\users | 34 | + | My report 35 | core_user\reportbuilder\datasource\users | 35 | + And the following "core_reportbuilder > Columns" exist: + | report | uniqueidentifier | + | My report 01 | user:fullname | + | My report 02 | user:fullname | + | My report 03 | user:fullname | + | My report 04 | user:fullname | + | My report 05 | user:fullname | + | My report 06 | user:fullname | + | My report 07 | user:fullname | + | My report 08 | user:fullname | + | My report 09 | user:fullname | + | My report 10 | user:fullname | + | My report 11 | user:fullname | + | My report 12 | user:fullname | + | My report 13 | user:fullname | + | My report 14 | user:fullname | + | My report 15 | user:fullname | + | My report 16 | user:fullname | + | My report 17 | user:fullname | + | My report 18 | user:fullname | + | My report 19 | user:fullname | + | My report 20 | user:fullname | + | My report 21 | user:fullname | + | My report 22 | user:fullname | + | My report 23 | user:fullname | + | My report 24 | user:fullname | + | My report 25 | user:fullname | + | My report 26 | user:fullname | + | My report 27 | user:fullname | + | My report 28 | user:fullname | + | My report 29 | user:fullname | + | My report 30 | user:fullname | + | My report 31 | user:fullname | + | My report 32 | user:fullname | + | My report 33 | user:fullname | + | My report 34 | user:fullname | + | My report 35 | user:fullname | + And the following "core_reportbuilder > Audiences" exist: + | report | configdata | classname | + | My report 01 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 02 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 03 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 04 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 05 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 06 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 07 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 08 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 09 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 10 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 11 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 12 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 13 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 14 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 15 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 16 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 17 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 18 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 19 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 20 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 21 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 22 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 23 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 24 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 25 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 26 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 27 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 28 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 29 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 30 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 31 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 32 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 33 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 34 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 35 | | core_reportbuilder\reportbuilder\audience\allusers | + And the following "users" exist: + | username | firstname | lastname | email | city | + | student1 | Lionel | Smith | lionel@example.com | Bilbao | + + Scenario: Open report in mobile + Given I enter the app + And I log in as "student1" + And I press the user menu button in the app + When I press "Reports" in the app + + # Find report in the screen + Then I should find "My report 03" in the app + And I press "My report 03" in the app + And I should find "My report 03" in the app + And I should find "Lionel Smith" in the app + But I should not find "My report 02" in the app + + Scenario: Open report in tablet + Given I enter the app + And I change viewport size to "1200x640" + And I log in as "student1" + And I press the user menu button in the app + When I press "Reports" in the app + + # Find report in the screen + Then I should find "My report 02" in the app + And I press "My report 02" in the app + And I should find "My report 02" in the app + And I should find "Lionel Smith" in the app + But I should not find "My report 03" in the app From 51420ada529782100c177814fecca76b4bd0e2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 1 Feb 2023 11:52:02 +0100 Subject: [PATCH 14/80] MOBILE-4065 chore: Change to strict comparison in event key names --- src/core/classes/aria-role-tab.ts | 16 ++++++++-------- .../components/show-password/show-password.ts | 4 ++-- .../rich-text-editor/rich-text-editor.ts | 4 ++-- .../features/login/pages/reconnect/reconnect.ts | 4 ++-- src/core/singletons/dom.ts | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/core/classes/aria-role-tab.ts b/src/core/classes/aria-role-tab.ts index 3c7ea6938f0..2965eb3dce9 100644 --- a/src/core/classes/aria-role-tab.ts +++ b/src/core/classes/aria-role-tab.ts @@ -29,19 +29,19 @@ export class CoreAriaRoleTab { * @param e Event. */ keyDown(tabFindIndex: string, e: KeyboardEvent): void { - if (e.key == ' ' || - e.key == 'Enter' || - e.key == 'Home' || - e.key == 'End' || - (this.isHorizontal() && (e.key == 'ArrowRight' || e.key == 'ArrowLeft')) || - (!this.isHorizontal() && (e.key == 'ArrowUp' ||e.key == 'ArrowDown')) + if (e.key === ' ' || + e.key === 'Enter' || + e.key === 'Home' || + e.key === 'End' || + (this.isHorizontal() && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) || + (!this.isHorizontal() && (e.key === 'ArrowUp' ||e.key === 'ArrowDown')) ) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } - if (e.key == ' ' || e.key == 'Enter') { + if (e.key === ' ' || e.key === 'Enter') { this.selectTabCandidate = tabFindIndex; } } @@ -64,7 +64,7 @@ export class CoreAriaRoleTab { e.stopPropagation(); e.stopImmediatePropagation(); - if (e.key == ' ' || e.key == 'Enter') { + if (e.key === ' ' || e.key === 'Enter') { if (this.selectTabCandidate === tabFindIndex) { this.selectTab(tabFindIndex, e); } diff --git a/src/core/components/show-password/show-password.ts b/src/core/components/show-password/show-password.ts index 2ab247d287d..12289e0d74c 100644 --- a/src/core/components/show-password/show-password.ts +++ b/src/core/components/show-password/show-password.ts @@ -132,7 +132,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { * @param event The mouse event. */ doNotBlur(event: Event): void { - if (event.type == 'keydown' && !this.isValidKeyboardKey(event)) { + if (event.type === 'keydown' && !this.isValidKeyboardKey(event)) { return; } @@ -147,7 +147,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { * @returns Wether space or enter have been pressed. */ protected isValidKeyboardKey(event: KeyboardEvent): boolean { - return event.key == ' ' || event.key == 'Enter'; + return event.key === ' ' || event.key === 'Enter'; } } diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index e2d28827ec3..6b0e660c391 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -391,7 +391,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit, this.stopBubble(event); - const move = event.key == 'ArrowLeft' ? -1 : +1; + const move = event.key === 'ArrowLeft' ? -1 : +1; const cursor = this.getCurrentCursorPosition(this.editorElement); this.setCurrentCursorPosition(this.editorElement, cursor + move); @@ -754,7 +754,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit, * @returns Wether space or enter have been pressed. */ protected isValidKeyboardKey(event: KeyboardEvent): boolean { - return event.key == ' ' || event.key == 'Enter'; + return event.key === ' ' || event.key === 'Enter'; } /** diff --git a/src/core/features/login/pages/reconnect/reconnect.ts b/src/core/features/login/pages/reconnect/reconnect.ts index 973eeaee7ad..70fd73df1c8 100644 --- a/src/core/features/login/pages/reconnect/reconnect.ts +++ b/src/core/features/login/pages/reconnect/reconnect.ts @@ -353,7 +353,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy { * @param e Event. */ keyDown(e: KeyboardEvent): void { - if (e.key == 'Escape') { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); } @@ -365,7 +365,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy { * @param e Event. */ keyUp(e: KeyboardEvent): void { - if (e.key == 'Escape') { + if (e.key === 'Escape') { this.cancel(e); } } diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 262e65ebe38..43e20abbeea 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -519,7 +519,7 @@ export class CoreDom { element.addEventListener('click', (event) => callback(event)); element.addEventListener('keydown', (event) => { - if ((event.key == ' ' || event.key == 'Enter')) { + if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); } From 15cdc017e58ea809b7a1ca023090bd241ca92a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 1 Feb 2023 11:52:14 +0100 Subject: [PATCH 15/80] MOBILE-4065 course: Fix course and module cards --- .../components/course-index/course-index.html | 2 +- .../components/module/core-course-module.html | 11 +-- .../core-courses-course-list-item.html | 8 +- .../core-courses-course-options-menu.html | 78 ++++++++++--------- src/theme/helpers/custom.mixins.scss | 1 + src/theme/theme.base.scss | 11 ++- 6 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/core/features/course/components/course-index/course-index.html b/src/core/features/course/components/course-index/course-index.html index dd97d4628fc..38ffbf7f3e6 100644 --- a/src/core/features/course/components/course-index/course-index.html +++ b/src/core/features/course/components/course-index/course-index.html @@ -32,7 +32,7 @@

class="expandable-status-icon" (ariaButtonClick)="toggleExpand($event, section)" [attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate" [attr.aria-expanded]="section.expanded" [attr.aria-controls]="'core-course-index-section-' + section.id" - [class.expandable-status-icon-expanded]="section.expanded" tabindex="0"> + [class.expandable-status-icon-expanded]="section.expanded"> diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index 5206b6397dd..3d8d08d2da1 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -1,10 +1,11 @@ - + - + }"> diff --git a/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html b/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html index eef29500ebe..94045bb3b22 100644 --- a/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html +++ b/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html @@ -1,8 +1,8 @@ + [class.core-course-list-card]="layout == 'card' || layout == 'summarycard'" [class.item-dimmed]="course.hidden" (click)="openCourse()" + button [attr.aria-label]="course.displayname || course.fullname"> -
+
@@ -27,7 +27,7 @@
- + - - - -

{{ prefetch.statusTranslatable | translate }}

-
-
- - - -

{{ 'addon.storagemanager.deletedata' | translate }}

-
-
- - - -

{{ 'core.courses.hidecourse' | translate }}

-
-
- - - -

{{ 'core.courses.show' | translate }}

-
-
- - - -

{{ 'core.courses.addtofavourites' | translate }}

-
-
- - - -

{{ 'core.courses.removefromfavourites' | translate }}

-
-
+ + + + + +

{{ prefetch.statusTranslatable | translate }}

+
+
+ + + +

{{ 'addon.storagemanager.deletedata' | translate }}

+
+
+ + + +

{{ 'core.courses.hidecourse' | translate }}

+
+
+ + + +

{{ 'core.courses.show' | translate }}

+
+
+ + + +

{{ 'core.courses.addtofavourites' | translate }}

+
+
+ + + +

{{ 'core.courses.removefromfavourites' | translate }}

+
+
+
diff --git a/src/theme/helpers/custom.mixins.scss b/src/theme/helpers/custom.mixins.scss index dac3ec43cec..dfafaa7b085 100644 --- a/src/theme/helpers/custom.mixins.scss +++ b/src/theme/helpers/custom.mixins.scss @@ -81,6 +81,7 @@ @mixin core-focus-style() { box-shadow: inset 0 0 var(--a11y-focus-width) 1px var(--a11y-focus-color); + border-radius: var(--border-radius); // Thicker option: // border: var(--a11y-focus-width) solid var(--a11y-focus-color); } diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index e7eec8172c9..de26df5cba9 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -897,12 +897,16 @@ img[core-external-content]:not([src]) { } ion-card { + box-shadow: var(--box-shadow); + margin: var(--ion-card-vertical-margin) var(--ion-card-horizontal-margin); border-width: var(--border-width); border-style: var(--border-style); border-color: var(--border-color); - box-shadow: var(--box-shadow); border-radius: var(--border-radius); - margin: var(--ion-card-vertical-margin) var(--ion-card-horizontal-margin); + + &::part(native) { + --border-width: 0; + } ion-item:only-child { --inner-border-width: 0px; @@ -1521,7 +1525,8 @@ ion-input.has-focus, .ion-focused ion-toggle:focus-within, .ion-focused ion-select:focus-within, .ion-focused ion-checkbox:focus-within, -.ion-focused ion-radio:focus-within { +.ion-focused ion-radio:focus-within, +ion-card:focus { @include core-focus(); } From fc98bf57965fae174a7e04f402cf557284815288 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 7 Feb 2023 12:57:22 +0100 Subject: [PATCH 16/80] MOBILE-4077 ReportBuilder: Sort reports list and update lang strings --- scripts/langindex.json | 6 +++--- src/core/features/reportbuilder/classes/reports-source.ts | 2 +- src/core/features/reportbuilder/lang.json | 2 +- src/core/features/reportbuilder/pages/list/list.html | 2 +- .../reportbuilder/services/handlers/reportbuilder.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index 325e56d4209..b451b9660db 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1653,9 +1653,9 @@ "core.currentdevice": "local_moodlemobileapp", "core.custom": "form", "core.reportbuilder.modifiedby": "tool_reportbuilder", - "core.reportbuilder.reportstab": "tool_reportbuilder", - "core.reportbuilder.reportsource": "tool_reportbuilder", - "core.reportbuilder.timecreated": "tool_reportbuilder", + "core.reportbuilder.reports": "moodle", + "core.reportbuilder.reportsource": "moodle", + "core.reportbuilder.timecreated": "moodle", "core.reportbuilder.showcolumns": "local_moodlemobileapp", "core.reportbuilder.hidecolumns": "local_moodlemobileapp", "core.datastoredoffline": "local_moodlemobileapp", diff --git a/src/core/features/reportbuilder/classes/reports-source.ts b/src/core/features/reportbuilder/classes/reports-source.ts index 31449689f3d..04f5e27ae25 100644 --- a/src/core/features/reportbuilder/classes/reports-source.ts +++ b/src/core/features/reportbuilder/classes/reports-source.ts @@ -41,7 +41,7 @@ export class CoreReportBuilderReportsSource extends CoreRoutedItemsManagerSource */ protected setItems(reports: CoreReportBuilderReport[], hasMoreItems: boolean): void { const sortedReports = reports.slice(0); - reports.sort((a, b) => a.timecreated < b.timecreated ? 1 : -1); + sortedReports.sort((a, b) => a.timemodified < b.timemodified ? 1 : -1); super.setItems(sortedReports, hasMoreItems); } diff --git a/src/core/features/reportbuilder/lang.json b/src/core/features/reportbuilder/lang.json index 299a7a10fc7..3ff518a5f9c 100644 --- a/src/core/features/reportbuilder/lang.json +++ b/src/core/features/reportbuilder/lang.json @@ -1,6 +1,6 @@ { "modifiedby": "Modified by", - "reportstab": "Reports", + "reports": "Reports", "reportsource": "Report source", "timecreated": "Time created", "showcolumns": "Show columns", diff --git a/src/core/features/reportbuilder/pages/list/list.html b/src/core/features/reportbuilder/pages/list/list.html index 832fc49d572..85295b1b6ce 100644 --- a/src/core/features/reportbuilder/pages/list/list.html +++ b/src/core/features/reportbuilder/pages/list/list.html @@ -4,7 +4,7 @@ -

{{ 'core.reportbuilder.reportstab' | translate }}

+

{{ 'core.reportbuilder.reports' | translate }}

diff --git a/src/core/features/reportbuilder/services/handlers/reportbuilder.ts b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts index 4a463f46e2c..9aacb15abc6 100644 --- a/src/core/features/reportbuilder/services/handlers/reportbuilder.ts +++ b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts @@ -45,11 +45,11 @@ export class CoreReportBuilderHandlerService implements CoreUserProfileHandler { return { class: 'core-report-builder', icon: 'fa-list-alt', - title: 'core.reportbuilder.reportstab', + title: 'core.reportbuilder.reports', action: async (event): Promise => { event.preventDefault(); event.stopPropagation(); - await CoreNavigator.navigate(`/${CoreReportBuilderHandlerService.PAGE_NAME}`); + await CoreNavigator.navigateToSitePath(CoreReportBuilderHandlerService.PAGE_NAME); }, }; } From 9a16548826eba30841b40c08feebabc6b9f187f1 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 7 Feb 2023 12:58:00 +0100 Subject: [PATCH 17/80] MOBILE-4077 ReportBuilder: Fix routing in mainmenu --- .../features/reportbuilder/reportbuilder.module.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/features/reportbuilder/reportbuilder.module.ts b/src/core/features/reportbuilder/reportbuilder.module.ts index 833c8df1fed..06dfaedf200 100644 --- a/src/core/features/reportbuilder/reportbuilder.module.ts +++ b/src/core/features/reportbuilder/reportbuilder.module.ts @@ -13,7 +13,9 @@ // limitations under the License. import { APP_INITIALIZER, NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { Routes } from '@angular/router'; +import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CoreUserDelegate } from '@features/user/services/user-delegate'; import { CoreReportBuilderHandler, CoreReportBuilderHandlerService } from './services/handlers/reportbuilder'; @@ -25,7 +27,11 @@ const routes: Routes = [ ]; @NgModule({ - imports: [RouterModule.forChild(routes)], + imports: [ + CoreMainMenuRoutingModule.forChild({ children: routes }), + CoreMainMenuTabRoutingModule.forChild(routes), + ], + exports: [CoreMainMenuRoutingModule], providers: [ { provide: APP_INITIALIZER, From cbbc5d0aaeaa72f728f6101d98597d4db1fcd0c7 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Tue, 7 Feb 2023 14:53:52 +0100 Subject: [PATCH 18/80] MOBILE-4077 ReportBuilder: Add filter advice in report-summary --- scripts/langindex.json | 1 + .../report-summary/report-summary.html | 17 +++++++++++++---- .../report-summary/report-summary.scss | 14 ++++++++++++++ .../components/report-summary/report-summary.ts | 1 + src/core/features/reportbuilder/lang.json | 1 + .../reportbuilder/reportbuilder.module.ts | 1 - 6 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 src/core/features/reportbuilder/components/report-summary/report-summary.scss diff --git a/scripts/langindex.json b/scripts/langindex.json index b451b9660db..bc5b434d32a 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1656,6 +1656,7 @@ "core.reportbuilder.reports": "moodle", "core.reportbuilder.reportsource": "moodle", "core.reportbuilder.timecreated": "moodle", + "core.reportbuilder.filtersapplied": "local_moodlemobileapp", "core.reportbuilder.showcolumns": "local_moodlemobileapp", "core.reportbuilder.hidecolumns": "local_moodlemobileapp", "core.datastoredoffline": "local_moodlemobileapp", diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.html b/src/core/features/reportbuilder/components/report-summary/report-summary.html index 7c37437c4b2..162a46a58d3 100644 --- a/src/core/features/reportbuilder/components/report-summary/report-summary.html +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.html @@ -22,10 +22,6 @@

- - -
@@ -37,3 +33,16 @@

+ + + + +

+ + + +

+
+
+
diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.scss b/src/core/features/reportbuilder/components/report-summary/report-summary.scss new file mode 100644 index 00000000000..32bb1fe4ef4 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.scss @@ -0,0 +1,14 @@ +@import "~theme/globals"; + +.filters-info { + padding-bottom: 1rem; +} + +ion-footer { + ion-icon { + font-size: 16px; + color: $blue; + margin-right: .3rem; + vertical-align: middle; + } +} diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.ts b/src/core/features/reportbuilder/components/report-summary/report-summary.ts index 712914e3afb..a08e8cd95fa 100644 --- a/src/core/features/reportbuilder/components/report-summary/report-summary.ts +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.ts @@ -21,6 +21,7 @@ import { ModalController } from '@singletons'; @Component({ selector: 'core-report-builder-report-summary', templateUrl: './report-summary.html', + styleUrls: ['./report-summary.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CoreReportBuilderReportSummaryComponent implements OnInit { diff --git a/src/core/features/reportbuilder/lang.json b/src/core/features/reportbuilder/lang.json index 3ff518a5f9c..08f70940e3f 100644 --- a/src/core/features/reportbuilder/lang.json +++ b/src/core/features/reportbuilder/lang.json @@ -1,6 +1,7 @@ { "modifiedby": "Modified by", "reports": "Reports", + "filtersapplied": "There may be filters applied to this view. To edit filters or change the sorting order, open this report on your browser.", "reportsource": "Report source", "timecreated": "Time created", "showcolumns": "Show columns", diff --git a/src/core/features/reportbuilder/reportbuilder.module.ts b/src/core/features/reportbuilder/reportbuilder.module.ts index 06dfaedf200..a905a6cedc0 100644 --- a/src/core/features/reportbuilder/reportbuilder.module.ts +++ b/src/core/features/reportbuilder/reportbuilder.module.ts @@ -28,7 +28,6 @@ const routes: Routes = [ @NgModule({ imports: [ - CoreMainMenuRoutingModule.forChild({ children: routes }), CoreMainMenuTabRoutingModule.forChild(routes), ], exports: [CoreMainMenuRoutingModule], From 7fac6895d9e8bd6d04471868bb9e596ff7cd095d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 7 Feb 2023 10:59:06 +0100 Subject: [PATCH 19/80] MOBILE-4065 directive: Improve ariaButtonClick directive --- .../user-avatar/core-user-avatar.html | 16 +++++--- .../components/user-avatar/user-avatar.scss | 3 -- src/core/directives/aria-button.ts | 26 +++++++++++-- src/core/directives/format-text.ts | 7 +--- src/core/directives/link.ts | 7 +--- .../user-menu-button/user-menu-button.html | 2 +- src/core/singletons/dom.ts | 39 ++++++++++++++++++- 7 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/core/components/user-avatar/core-user-avatar.html b/src/core/components/user-avatar/core-user-avatar.html index 1e90d6bf319..67eae965c7e 100644 --- a/src/core/components/user-avatar/core-user-avatar.html +++ b/src/core/components/user-avatar/core-user-avatar.html @@ -1,10 +1,14 @@ - + - + + + + + diff --git a/src/core/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss index 38eea550074..f2028ece88a 100644 --- a/src/core/components/user-avatar/user-avatar.scss +++ b/src/core/components/user-avatar/user-avatar.scss @@ -5,9 +5,6 @@ width: var(--core-avatar-size); height: var(--core-avatar-size); - .clickable { - cursor: pointer; - } img { border-radius: 50%; width: var(--core-avatar-size); diff --git a/src/core/directives/aria-button.ts b/src/core/directives/aria-button.ts index 3d0d2959312..ec05e4cabf4 100644 --- a/src/core/directives/aria-button.ts +++ b/src/core/directives/aria-button.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, ElementRef, OnInit, Output, EventEmitter } from '@angular/core'; +import { Directive, ElementRef, OnInit, Output, EventEmitter, OnChanges, SimpleChanges, Input } from '@angular/core'; import { CoreDom } from '@singletons/dom'; /** @@ -21,10 +21,11 @@ import { CoreDom } from '@singletons/dom'; @Directive({ selector: '[ariaButtonClick]', }) -export class CoreAriaButtonClickDirective implements OnInit { +export class CoreAriaButtonClickDirective implements OnInit, OnChanges { protected element: HTMLElement; + @Input() disabled = false; @Output() ariaButtonClick = new EventEmitter(); constructor( @@ -34,10 +35,27 @@ export class CoreAriaButtonClickDirective implements OnInit { } /** - * Initialize actions. + * @inheritdoc */ ngOnInit(): void { - CoreDom.onActivate(this.element, (event) => this.ariaButtonClick.emit(event)); + CoreDom.initializeClickableElementA11y(this.element, (event) => this.ariaButtonClick.emit(event)); + } + + /** + * @inheritdoc + */ + ngOnChanges(changes: SimpleChanges): void { + if (!changes.disabled) { + return; + } + + if (this.element.getAttribute('tabindex') === '0' && this.disabled) { + this.element.setAttribute('tabindex', '-1'); + } + + if (this.element.getAttribute('tabindex') === '-1' && !this.disabled) { + this.element.setAttribute('tabindex', '0'); + } } } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 7fdd4e165b2..05e911330f2 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -610,12 +610,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo return; } - if (element.tagName !== 'BUTTON' && element.tagName !== 'A') { - element.setAttribute('tabindex', '0'); - element.setAttribute('role', 'button'); - } - - CoreDom.onActivate(element, async (event) => { + CoreDom.initializeClickableElementA11y(element, async (event) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/core/directives/link.ts b/src/core/directives/link.ts index 86d2c18efa6..7511193d1fc 100644 --- a/src/core/directives/link.ts +++ b/src/core/directives/link.ts @@ -57,12 +57,7 @@ export class CoreLinkDirective implements OnInit { * Function executed when the component is initialized. */ ngOnInit(): void { - if (this.element.tagName != 'BUTTON' && this.element.tagName != 'A') { - this.element.setAttribute('tabindex', '0'); - this.element.setAttribute('role', 'button'); - } - - CoreDom.onActivate(this.element, (event) => this.performAction(event)); + CoreDom.initializeClickableElementA11y(this.element, (event) => this.performAction(event)); } /** diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html index 1282c55abac..5e8829b0bd9 100644 --- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html +++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html @@ -1,4 +1,4 @@ diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 43e20abbeea..6e6b3d8d8de 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -514,8 +514,26 @@ export class CoreDom { * * @param element Element to listen to events. * @param callback Callback to call when clicked or the key is pressed. + * @deprecated since 4.1.1: Use initializeClickableElementA11y instead. */ - static onActivate(element: HTMLElement, callback: (event: MouseEvent | KeyboardEvent) => void): void { + static onActivate( + element: HTMLElement & {disabled?: boolean}, + callback: (event: MouseEvent | KeyboardEvent) => void, + ): void { + this.initializeClickableElementA11y(element, callback); + } + + /** + * Initializes a clickable element a11y calling the click action when pressed enter or space + * and adding tabindex and role if needed. + * + * @param element Element to listen to events. + * @param callback Callback to call when clicked or the key is pressed. + */ + static initializeClickableElementA11y( + element: HTMLElement & {disabled?: boolean}, + callback: (event: MouseEvent | KeyboardEvent) => void, + ): void { element.addEventListener('click', (event) => callback(event)); element.addEventListener('keydown', (event) => { @@ -526,10 +544,27 @@ export class CoreDom { }); element.addEventListener('keyup', (event) => { - if ((event.key == ' ' || event.key == 'Enter')) { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + callback(event); } }); + + if (element.tagName !== 'BUTTON' && element.tagName !== 'A') { + // Set tabindex if not previously set. + if (element.getAttribute('tabindex') === null) { + element.setAttribute('tabindex', element.disabled ? '-1' : '0'); + } + + // Set role if not previously set. + if (!element.getAttribute('role')) { + element.setAttribute('role', 'button'); + } + + element.classList.add('clickable'); + } } } From bff59a0e5494491f471bc5ba3017a19931010d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 1 Feb 2023 11:06:13 +0100 Subject: [PATCH 20/80] MOBILE-4065 calendar: Fix calendar days focus --- .../components/calendar/addon-calendar-calendar.html | 12 ++++++------ .../calendar/components/calendar/calendar.scss | 1 - src/core/components/swipe-slides/swipe-slides.html | 2 +- src/theme/theme.base.scss | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index b355dd1e510..3578f740c3d 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -33,7 +33,7 @@

- +
@@ -57,9 +57,9 @@

"today": month.isCurrentMonth && day.istoday, "weekend": day.isweekend, "duration_finish": day.haslastdayofevent - }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0" - (ariaButtonClick)="dayClicked(day.mday)"> -

+ }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" + (ariaButtonClick)="dayClicked(day.mday)" [tabindex]="activeView ? 0 : -1"> +

{{ day.periodName | translate }}

@@ -72,8 +72,8 @@

+ [class.addon-calendar-event-past]="event.ispast" (ariaButtonClick)="eventClicked(event, $event)" + [tabindex]="activeView ? 0 : -1"> diff --git a/src/addons/calendar/components/calendar/calendar.scss b/src/addons/calendar/components/calendar/calendar.scss index 7f6833c23c3..aea32bb2cf6 100644 --- a/src/addons/calendar/components/calendar/calendar.scss +++ b/src/addons/calendar/components/calendar/calendar.scss @@ -25,7 +25,6 @@ @include border-end(1px, solid var(--addon-calendar-border-color)); overflow: hidden; min-height: 60px; - cursor: pointer; &:first-child { @include padding-horizontal(10px, null); diff --git a/src/core/components/swipe-slides/swipe-slides.html b/src/core/components/swipe-slides/swipe-slides.html index 8b086075063..65ab4c92a3f 100644 --- a/src/core/components/swipe-slides/swipe-slides.html +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -1,5 +1,5 @@ - + diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index de26df5cba9..38b019c0e9c 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1581,7 +1581,7 @@ ion-item.item { outline: none; } -textarea, button, select, input, a { +textarea, button, select, input, a, .clickable { &:focus { @include core-focus-style(); outline: none; From 31a275a6fe990bbc7a32a3ccb7a26e8ed59a5d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 1 Feb 2023 13:57:14 +0100 Subject: [PATCH 21/80] MOBILE-4065 forum: Fix forum focus problems --- .../mod/forum/components/index/index.html | 37 +++++++++---------- .../mod/forum/components/index/index.scss | 14 +++++-- .../core-editor-rich-text-editor.html | 33 +++++++++-------- .../rich-text-editor/rich-text-editor.scss | 5 ++- 4 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/addons/mod/forum/components/index/index.html b/src/addons/mod/forum/components/index/index.html index e4d953a31d4..b53392089a0 100644 --- a/src/addons/mod/forum/components/index/index.html +++ b/src/addons/mod/forum/components/index/index.html @@ -74,26 +74,20 @@ [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions?.getItemAriaCurrent(discussion)" (click)="discussions?.select(discussion)" button> -
-

- - - - - -

- - - -
+

+ + + + + +

- +
{{discussion.userfullname}} @@ -136,6 +130,11 @@ + + +
- @@ -25,69 +25,70 @@ @@ -105,20 +106,20 @@ -
diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss index 606f54c1e22..a335fddcb5e 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -149,8 +149,9 @@ background-color: var(--toobar-background); } - &.toolbar-arrow-hidden { - opacity: 0; + &[disabled], + &:disabled { + opacity: .5; } } } From e75fb3404b182f8dc11bc33ecd1dda725713af19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 2 Feb 2023 12:30:05 +0100 Subject: [PATCH 22/80] MOBILE-4065 chore: Update Ionic minor version to 5.9.4 --- package-lock.json | 20 ++++++++++---------- package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a020e161c9..c3597667b5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4319,11 +4319,11 @@ } }, "@ionic/angular": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.2.tgz", - "integrity": "sha512-5GzKg+l4au3xFECky2v/USlRsmTAXgvNO5Zalt7NUXc//VJIL2lQvswojE6FBWuM/xR5W0CWbJdFth19TaZWVQ==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.4.tgz", + "integrity": "sha512-U/85FePF48VaZXTudTwpVXDqhGmYfarl/7vki7a4umnIORnWtHqD2/pXsqqZ/O1EcbALwULYIeVXAfkFpPd2wQ==", "requires": { - "@ionic/core": "5.9.2", + "@ionic/core": "5.9.4", "tslib": "^1.9.3" }, "dependencies": { @@ -4666,9 +4666,9 @@ } }, "@ionic/core": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.2.tgz", - "integrity": "sha512-1ZqSBS8R6tGQsc+LsLxIRv0q3Ww6jwgJXLvdn6FmVWfpPbBvT+CjCuU9hqJ5qwM+atErblUMYSexvvpws8lGAA==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.4.tgz", + "integrity": "sha512-Ngz9yVT6fIiGdSxxBer8uJxP4w6PasvohYpLxhtMgYiWnyIu0vZra2ui3HrYukCzUo5/SbNPiUr1l7cj1E+7qw==", "requires": { "@stencil/core": "^2.4.0", "ionicons": "^5.5.3", @@ -5852,9 +5852,9 @@ } }, "@stencil/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz", - "integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg==" + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.2.tgz", + "integrity": "sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw==" }, "@storybook/addon-controls": { "version": "6.1.21", diff --git a/package.json b/package.json index e359872dabf..e1d7d637a5a 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "@ionic-native/status-bar": "5.36.0", "@ionic-native/web-intent": "5.36.0", "@ionic-native/zip": "5.36.0", - "@ionic/angular": "5.9.2", + "@ionic/angular": "5.9.4", "@moodlehq/cordova-plugin-file-opener": "3.0.5-moodle.1", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", From a2788e2e807f45313fbfb08dd63daa4166bd414b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 2 Feb 2023 13:24:28 +0100 Subject: [PATCH 23/80] MOBILE-4065 styles: Fix items with input focus styles --- src/theme/theme.base.scss | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 38b019c0e9c..26c7fbdd62b 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1518,20 +1518,24 @@ ion-item.item-input.item-multiple-inputs { } // Focus highlight for accessibility. -ion-item.item-input.ion-focused:not(:focus), -.ion-focused, -ion-item.ion-activatable.ion-focused:not(:focus), +.ion-focused:not(.item-multiple-inputs):not(:focus), ion-input.has-focus, -.ion-focused ion-toggle:focus-within, -.ion-focused ion-select:focus-within, -.ion-focused ion-checkbox:focus-within, -.ion-focused ion-radio:focus-within, ion-card:focus { @include core-focus(); } +.ion-focused.item-multiple-inputs, +.ion-focused.ion-activatable { + ion-toggle:focus-within, + ion-select:focus-within, + ion-checkbox:focus-within, + ion-radio:focus-within { + @include core-focus(); + } +} // Treat cases where there's a focusable element inside an item, like a button. -ion-item.ion-focused:not(:focus), +ion-item.item-input:not(.item-multiple-inputs):not(:focus), +ion-item.item-has-focus:not(.item-multiple-inputs):not(:focus), ion-item.item-input ion-input.has-focus { position: relative; &::after { @@ -1588,6 +1592,10 @@ textarea, button, select, input, a, .clickable { } } +.ion-focused:not(.item-multiple-inputs):not(:focus) .clickable:focus { + box-shadow: none; +} + ion-loading:focus-visible, ion-alert:focus-visible, ion-popover:focus-visible, From f11819f698e269daf4b79f5caca112d7d2a99ec8 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 6 Feb 2023 11:26:51 +0100 Subject: [PATCH 24/80] MOBILE-4069 behat: Fix timeline test --- .../block/timeline/tests/behat/basic_usage.feature | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/addons/block/timeline/tests/behat/basic_usage.feature b/src/addons/block/timeline/tests/behat/basic_usage.feature index f69fc3f3a9c..9042ad3078f 100644 --- a/src/addons/block/timeline/tests/behat/basic_usage.feature +++ b/src/addons/block/timeline/tests/behat/basic_usage.feature @@ -25,11 +25,11 @@ Feature: Timeline block. | assign | C1 | assign03 | Assignment 03 | ##tomorrow## | | assign | C2 | assign04 | Assignment 04 | ##+2 days## | | assign | C1 | assign05 | Assignment 05 | ##+5 days## | - | assign | C2 | assign06 | Assignment 06 | ##+1 month## | - | assign | C2 | assign07 | Assignment 07 | ##+1 month## | - | assign | C3 | assign08 | Assignment 08 | ##+1 month## | - | assign | C2 | assign09 | Assignment 09 | ##+1 month## | - | assign | C1 | assign10 | Assignment 10 | ##+1 month## | + | assign | C2 | assign06 | Assignment 06 | ##+31 days## | + | assign | C2 | assign07 | Assignment 07 | ##+31 days## | + | assign | C3 | assign08 | Assignment 08 | ##+31 days## | + | assign | C2 | assign09 | Assignment 09 | ##+31 days## | + | assign | C1 | assign10 | Assignment 10 | ##+31 days## | | assign | C1 | assign11 | Assignment 11 | ##+6 months## | | assign | C1 | assign12 | Assignment 12 | ##+6 months## | | assign | C1 | assign13 | Assignment 13 | ##+6 months## | From ebb6e393cf58cc8fe0b1046fbcfe6c5280abe1d9 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 7 Feb 2023 12:07:10 +0100 Subject: [PATCH 25/80] MOBILE-4069 book: Fix PTR in book index page --- src/addons/mod/book/components/index/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index 838f154e85c..191a698faa1 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -60,6 +60,13 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp ]); } + /** + * @inheritdoc + */ + protected async invalidateContent(): Promise { + await AddonModBook.invalidateContent(this.module.id, this.courseId); + } + /** * Load book data. * From 8f826185b6d1e34210b84f5dc09e85d20ccabfda Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 7 Feb 2023 13:04:33 +0100 Subject: [PATCH 26/80] MOBILE-4069 behat: Allow searching text split in different elements --- src/testing/services/behat-dom.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index 28fd807f554..270ba3fc821 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -24,6 +24,8 @@ import { TestingBehatElementLocator, TestingBehatFindOptions } from './behat-run @Injectable({ providedIn: 'root' }) export class TestingBehatDomUtilsService { + protected static readonly MULTI_ELEM_ALLOWED = ['P', 'SPAN', 'ION-LABEL']; + /** * Check if an element is clickable. * @@ -154,6 +156,7 @@ export class TestingBehatDomUtilsService { }, ); + let fallbackCandidates: ElementsWithExact[] = []; let currentNode: Node | null = null; // eslint-disable-next-line no-cond-assign while (currentNode = treeWalker.nextNode()) { @@ -202,9 +205,24 @@ export class TestingBehatDomUtilsService { elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text, options)); } } + + // Allow searching text split into different elements in some cases. + if ( + elements.length === 0 && + currentNode instanceof HTMLElement && + TestingBehatDomUtilsService.MULTI_ELEM_ALLOWED.includes(currentNode.tagName) && + currentNode.innerText.includes(text) + ) { + // Only keep the child elements in the candidates list. + fallbackCandidates = fallbackCandidates.filter(entry => !entry.element.contains(currentNode)); + fallbackCandidates.push({ + element: currentNode, + exact: currentNode.innerText.trim() == text, + }); + } } - return elements; + return elements.length > 0 ? elements : fallbackCandidates; } /** From 0732722882b0e9197c70cb5c9e1abaa3074a67ee Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 7 Feb 2023 16:20:48 +0100 Subject: [PATCH 27/80] MOBILE-4069 behat: Test book swipe and numbering --- .../tests/behat/behat_app.php | 16 +- .../index/addon-mod-book-index.html | 4 +- src/addons/mod/book/components/toc/toc.html | 4 +- src/addons/mod/book/services/book.ts | 4 +- .../mod/book/tests/behat/basic_usage.feature | 139 ++++++++++++++++-- src/testing/services/behat-runtime.ts | 67 ++++++++- 6 files changed, 211 insertions(+), 23 deletions(-) diff --git a/local_moodleappbehat/tests/behat/behat_app.php b/local_moodleappbehat/tests/behat/behat_app.php index 7a815ec17a6..04395fbaf4b 100644 --- a/local_moodleappbehat/tests/behat/behat_app.php +++ b/local_moodleappbehat/tests/behat/behat_app.php @@ -237,13 +237,21 @@ public function i_load_more_items_in_the_app(bool $not = false) { /** * Trigger swipe gesture. * - * @When /^I swipe to the (left|right) in the app$/ + * @When /^I swipe to the (left|right) (in (".+") )?in the app$/ * @param string $direction Swipe direction + * @param bool $hasLocator Whether a reference locator is used. + * @param string $locator Reference locator. */ - public function i_swipe_in_the_app(string $direction) { - $method = 'swipe' . ucwords($direction); + public function i_swipe_in_the_app(string $direction, bool $hasLocator = false, string $locator = '') { + if ($hasLocator) { + $locator = $this->parse_element_locator($locator); + } + + $result = $this->zone_js("swipe('$direction'" . ($hasLocator ? ", $locator" : '') . ')'); - $this->zone_js("getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); + if ($result !== 'OK') { + throw new DriverException('Error when swiping - ' . $result); + } $this->wait_for_pending_js(); diff --git a/src/addons/mod/book/components/index/addon-mod-book-index.html b/src/addons/mod/book/components/index/addon-mod-book-index.html index 380582d583a..b186dfb3c12 100644 --- a/src/addons/mod/book/components/index/addon-mod-book-index.html +++ b/src/addons/mod/book/components/index/addon-mod-book-index.html @@ -24,8 +24,8 @@

{{ 'addon.mod_book.toc' | translate }}

(click)="openBook(chapter.id)">

- {{chapter.indexNumber}}  - •  + {{chapter.indexNumber}} +

diff --git a/src/addons/mod/book/components/toc/toc.html b/src/addons/mod/book/components/toc/toc.html index 42165ea288e..663370ab8e6 100644 --- a/src/addons/mod/book/components/toc/toc.html +++ b/src/addons/mod/book/components/toc/toc.html @@ -17,8 +17,8 @@

{{ 'addon.mod_book.toc' | translate }}

[attr.aria-current]="selected == chapter.id ? 'page' : 'false'" button [class.item-dimmed]="chapter.hidden" detail="false">

- {{chapter.indexNumber}}  - •  + {{chapter.indexNumber}} +

diff --git a/src/addons/mod/book/services/book.ts b/src/addons/mod/book/services/book.ts index 8681ef369ec..2365c52919b 100644 --- a/src/addons/mod/book/services/book.ts +++ b/src/addons/mod/book/services/book.ts @@ -291,7 +291,9 @@ export class AddonModBookProvider { }); } - chapterNumber++; + if (!parseInt(chapter.hidden, 10)) { + chapterNumber++; + } }); return chapters; diff --git a/src/addons/mod/book/tests/behat/basic_usage.feature b/src/addons/mod/book/tests/behat/basic_usage.feature index 6e24b7b2a27..65a09b54e7d 100755 --- a/src/addons/mod/book/tests/behat/basic_usage.feature +++ b/src/addons/mod/book/tests/behat/basic_usage.feature @@ -27,6 +27,7 @@ Feature: Test basic usage of book activity in app | Basic book | Hidden chapter | This is a hidden chapter | 0 | 1 | 4 | | Basic book | Hidden subchapter | This is a hidden subchapter | 1 | 1 | 5 | | Basic book | Chapt 3 | This is the third chapter | 0 | 0 | 6 | + | Basic book | Last hidden | Another hidden subchapter | 1 | 1 | 7 | Scenario: View book table of contents (student) Given I entered the course "Course 1" as "student1" in the app @@ -39,6 +40,7 @@ Feature: Test basic usage of book activity in app And I should find "Start" in the app But I should not find "Hidden chapter" in the app And I should not find "Hidden subchapter" in the app + And I should not find "Last hidden" in the app And I should not find "This is the first chapter" in the app When I press "Start" in the app @@ -49,6 +51,7 @@ Feature: Test basic usage of book activity in app And I should find "Chapt 3" in the app But I should not find "Hidden chapter" in the app And I should not find "Hidden subchapter" in the app + And I should not find "Last hidden" in the app Scenario: View book table of contents (teacher) Given I entered the course "Course 1" as "teacher1" in the app @@ -60,6 +63,7 @@ Feature: Test basic usage of book activity in app And I should find "Hidden chapter" in the app And I should find "Hidden subchapter" in the app And I should find "Chapt 3" in the app + And I should find "Last hidden" in the app And I should find "Start" in the app And I should not find "This is the first chapter" in the app @@ -71,6 +75,7 @@ Feature: Test basic usage of book activity in app And I should find "Hidden chapter" in the app And I should find "Hidden subchapter" in the app And I should find "Chapt 3" in the app + And I should find "Last hidden" in the app Scenario: Open chapters from table of contents Given I entered the course "Course 1" as "student1" in the app @@ -127,7 +132,26 @@ Feature: Test basic usage of book activity in app And I should find "4 / 4" in the app But I should not find "This is the first chapter" in the app - # TODO: Test navigate using swipe. + # Navigate using swipe. + When I swipe to the left in "Chapt 3" "ion-slides" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "4 / 4" in the app + + When I swipe to the right in "Chapt 3" "ion-slides" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 4" in the app + + When I swipe to the right in "Chapt 2" "ion-slides" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 4" in the app + + When I swipe to the left in "Chapt 1.1" "ion-slides" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 4" in the app Scenario: View and navigate book contents (teacher) Given I entered the course "Course 1" as "teacher1" in the app @@ -135,36 +159,36 @@ Scenario: View and navigate book contents (teacher) And I press "Start" in the app Then I should find "Chapt 1" in the app And I should find "This is the first chapter" in the app - And I should find "1 / 6" in the app + And I should find "1 / 7" in the app When I press "Next" in the app Then I should find "Chapt 1.1" in the app And I should find "This is a subchapter" in the app - And I should find "2 / 6" in the app + And I should find "2 / 7" in the app But I should not find "This is the first chapter" in the app When I press "Next" in the app Then I should find "Chapt 2" in the app And I should find "This is the second chapter" in the app - And I should find "3 / 6" in the app + And I should find "3 / 7" in the app But I should not find "This is a subchapter" in the app When I press "Next" in the app Then I should find "Hidden chapter" in the app And I should find "This is a hidden chapter" in the app - And I should find "4 / 6" in the app + And I should find "4 / 7" in the app But I should not find "This is the second chapter" in the app When I press "Next" in the app Then I should find "Hidden subchapter" in the app And I should find "This is a hidden subchapter" in the app - And I should find "5 / 6" in the app + And I should find "5 / 7" in the app But I should not find "This is a hidden chapter" in the app When I press "Previous" in the app Then I should find "Hidden chapter" in the app And I should find "This is a hidden chapter" in the app - And I should find "4 / 6" in the app + And I should find "4 / 7" in the app But I should not find "This is a hidden subchapter" in the app # Navigate using TOC. @@ -172,20 +196,113 @@ Scenario: View and navigate book contents (teacher) And I press "Chapt 1" in the app Then I should find "Chapt 1" in the app And I should find "This is the first chapter" in the app - And I should find "1 / 6" in the app + And I should find "1 / 7" in the app But I should not find "This is a hidden chapter" in the app When I press "Table of contents" in the app And I press "Hidden subchapter" in the app Then I should find "Hidden subchapter" in the app And I should find "This is a hidden subchapter" in the app - And I should find "5 / 6" in the app + And I should find "5 / 7" in the app But I should not find "This is the first chapter" in the app - # TODO: Test navigate using swipe. + # Navigate using swipe. + When I swipe to the left in "Hidden subchapter" "ion-slides" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "6 / 7" in the app + + When I swipe to the left in "Chapt 3" "ion-slides" in the app + Then I should find "Last hidden" in the app + And I should find "Another hidden subchapter" in the app + And I should find "7 / 7" in the app + + When I swipe to the left in "Last hidden" "ion-slides" in the app + Then I should find "Last hidden" in the app + And I should find "Another hidden subchapter" in the app + And I should find "7 / 7" in the app + + When I swipe to the right in "Last hidden" "ion-slides" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "6 / 7" in the app Scenario: Link to book opens chapter content Given I entered the book activity "Basic book" on course "Course 1" as "student1" in the app Then I should find "This is the first chapter" in the app - # TODO: Scenario to test book numbering (numbers, bullets, etc.). + Scenario: Test numbering (student) + Given the following "activities" exist: + | activity | name | intro | course | idnumber | numbering | + | book | Bull book | Test book description | C1 | book2 | 2 | + | book | Ind book | Test book description | C1 | book2 | 3 | + | book | None book | Test book description | C1 | book2 | 0 | + And the following "mod_book > chapter" exist: + | book | title | content | subchapter | hidden | pagenum | + | Bull book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | Ind book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | None book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + And I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + Then I should find "1. Chapt 1" in the app + And I should find "1.1. Chapt 1.1" in the app + And I should find "2. Chapt 2" in the app + And I should find "3. Chapt 3" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "1. Chapt 1" in the app + And I should find "1.1. Chapt 1.1" in the app + And I should find "2. Chapt 2" in the app + And I should find "3. Chapt 3" in the app + + When I press "Close" in the app + And I press the back button in the app + And I press the back button in the app + And I press "Bull book" in the app + Then I should find "• Chapt 1" in the app + But I should not find "1. Chapt 1" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "• Chapt 1" in the app + But I should not find "1. Chapt 1" in the app + + When I press "Close" in the app + And I press the back button in the app + And I press the back button in the app + And I press "Ind book" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + When I press "Close" in the app + And I press the back button in the app + And I press the back button in the app + And I press "None book" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + Scenario: Test numbering (teacher) + Given I entered the course "Course 1" as "teacher1" in the app + And I press "Basic book" in the app + Then I should find "1. Chapt 1" in the app + And I should find "1.1. Chapt 1.1" in the app + And I should find "2. Chapt 2" in the app + And I should find "x. Hidden chapter" in the app + And I should find "x.x. Hidden subchapter" in the app + And I should find "3. Chapt 3" in the app + And I should find "3.x. Last hidden" in the app diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index 892667cca59..0f7eb50527a 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -28,6 +28,8 @@ import { CoreDom } from '@singletons/dom'; import { Injectable } from '@angular/core'; import { CoreSites, CoreSitesProvider } from '@services/sites'; import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; +import { CoreSwipeNavigationDirective } from '@directives/swipe-navigation'; +import { IonSlides } from '@ionic/angular'; /** * Behat runtime servive with public API. @@ -493,13 +495,36 @@ export class TestingBehatRuntimeService { * * @param selector Element selector * @param className Constructor class name + * @param referenceLocator The locator to the reference element to start looking for. If not specified, document body. * @returns Component instance */ - getAngularInstance(selector: string, className: string): T | null { - this.log('Action - Get Angular instance ' + selector + ', ' + className); + getAngularInstance( + selector: string, + className: string, + referenceLocator?: TestingBehatElementLocator, + ): T | null { + this.log('Action - Get Angular instance ' + selector + ', ' + className, referenceLocator); + + let startingElement: HTMLElement | undefined = document.body; + let queryPrefix = ''; + + if (referenceLocator) { + startingElement = TestingBehatDomUtils.findElementBasedOnText(referenceLocator, { + onlyClickable: false, + containerName: '', + }); + + if (!startingElement) { + return null; + } + } else { + // Searching the whole DOM, search only in visible pages. + queryPrefix = '.ion-page:not(.ion-page-hidden) '; + } // eslint-disable-next-line @typescript-eslint/no-explicit-any - const activeElement = Array.from(document.querySelectorAll(`.ion-page:not(.ion-page-hidden) ${selector}`)).pop(); + const activeElement = Array.from(startingElement.querySelectorAll(`${queryPrefix}${selector}`)).pop() ?? + startingElement.closest(selector); if (!activeElement || !activeElement.__ngContext__) { return null; @@ -565,6 +590,42 @@ export class TestingBehatRuntimeService { return 'OK'; } + /** + * Swipe in the app. + * + * @param direction Left or right. + * @param locator Element locator to swipe. If not specified, swipe in the first ion-content found. + * @returns OK if successful, or ERROR: followed by message + */ + swipe(direction: string, locator?: TestingBehatElementLocator): string { + this.log('Action - Swipe', { direction, locator }); + + if (locator) { + // Locator specified, try to find ion-slides first. + const instance = this.getAngularInstance('ion-slides', 'IonSlides', locator); + if (instance) { + direction === 'left' ? instance.slideNext() : instance.slidePrev(); + + return 'OK'; + } + } + + // No locator specified or ion-slides not found, search swipe navigation now. + const instance = this.getAngularInstance( + 'ion-content', + 'CoreSwipeNavigationDirective', + locator, + ); + + if (!instance) { + return 'ERROR: Element to swipe not found.'; + } + + direction === 'left' ? instance.swipeLeft() : instance.swipeRight(); + + return 'OK'; + } + } export const TestingBehatRuntime = makeSingleton(TestingBehatRuntimeService); From 8a6782495d84ca7ab6d661a56115ddecc2b55144 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 8 Feb 2023 10:47:03 +0100 Subject: [PATCH 28/80] MOBILE-4239 config: Increase version to 4.1.1 --- config.xml | 6 +++--- moodle.config.json | 4 ++-- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config.xml b/config.xml index a97ad98de26..800051a32d6 100644 --- a/config.xml +++ b/config.xml @@ -1,5 +1,5 @@ - + Moodle Moodle official app Moodle Mobile team @@ -27,7 +27,7 @@ - + @@ -236,7 +236,7 @@ - 4.1.0 + 4.1.1 diff --git a/moodle.config.json b/moodle.config.json index 103c210eac8..061b6a3ed31 100644 --- a/moodle.config.json +++ b/moodle.config.json @@ -1,8 +1,8 @@ { "app_id": "com.moodle.moodlemobile", "appname": "Moodle Mobile", - "versioncode": 41001, - "versionname": "4.1.0", + "versioncode": 41100, + "versionname": "4.1.1", "cache_update_frequency_usually": 420000, "cache_update_frequency_often": 1200000, "cache_update_frequency_sometimes": 3600000, diff --git a/package-lock.json b/package-lock.json index c3597667b5f..94475288876 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "4.1.0", + "version": "4.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index e1d7d637a5a..61b897323a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "4.1.0", + "version": "4.1.1", "description": "The official app for Moodle.", "author": { "name": "Moodle Pty Ltd.", From 7341ca28c03deae52d7a18f5f188f09abb6502f8 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 24 Jan 2023 14:12:28 +0100 Subject: [PATCH 29/80] MOBILE-4166 npm: Install ogv and video.js --- package-lock.json | 296 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 + 2 files changed, 287 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94475288876..2c56f638d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3618,7 +3618,6 @@ "version": "7.9.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz", "integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } @@ -9255,6 +9254,71 @@ "eslint-visitor-keys": "^2.0.0" } }, + "@videojs/http-streaming": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.15.1.tgz", + "integrity": "sha512-/uuN3bVkEeJAdrhu5Hyb19JoUo3CMys7yf2C1vUjeL1wQaZ4Oe8JrZzRrnWZ0rjvPgKfNLPXQomsRtgrMoRMJQ==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "3.0.5", + "aes-decrypter": "3.1.3", + "global": "^4.4.0", + "m3u8-parser": "4.8.0", + "mpd-parser": "^0.22.1", + "mux.js": "6.0.1", + "video.js": "^6 || ^7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, + "@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "requires": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, + "@videojs/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==", + "requires": { + "@babel/runtime": "^7.5.5", + "global": "~4.4.0", + "is-function": "^1.0.1" + } + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -9430,6 +9494,11 @@ "@xtuc/long": "4.2.2" } }, + "@xmldom/xmldom": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.6.tgz", + "integrity": "sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==" + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -9567,6 +9636,32 @@ } } }, + "aes-decrypter": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.3.tgz", + "integrity": "sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0", + "pkcs7": "^1.0.4" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "agent-base": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", @@ -15674,8 +15769,7 @@ "dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", - "dev": true + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "domain-browser": { "version": "1.2.0", @@ -18627,7 +18721,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", - "dev": true, "requires": { "min-document": "^2.19.0", "process": "^0.11.10" @@ -20030,6 +20123,11 @@ "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", "dev": true }, + "individual": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz", + "integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==" + }, "infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", @@ -20545,8 +20643,7 @@ "is-function": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", - "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", - "dev": true + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" }, "is-generator-fn": { "version": "2.1.0", @@ -22371,6 +22468,11 @@ "source-map-support": "^0.5.5" } }, + "keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==" + }, "keytar": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.2.0.tgz", @@ -22940,6 +23042,31 @@ "yallist": "^4.0.0" } }, + "m3u8-parser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.8.0.tgz", + "integrity": "sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "macos-release": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", @@ -23425,7 +23552,6 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", - "dev": true, "requires": { "dom-walk": "^0.1.0" } @@ -23708,6 +23834,32 @@ } } }, + "mpd-parser": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz", + "integrity": "sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "@xmldom/xmldom": "^0.8.3", + "global": "^4.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -23740,6 +23892,30 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, + "mux.js": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-6.0.1.tgz", + "integrity": "sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==", + "requires": { + "@babel/runtime": "^7.11.2", + "global": "^4.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "nan": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", @@ -24875,6 +25051,29 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "ogv": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/ogv/-/ogv-1.8.9.tgz", + "integrity": "sha512-tQA2E3E2PzdWqxIaI5X8q8Vxvj1Ap3JSZmD1MfnA+cTY3o0t+06zY4RKXckQ9pxeqGy/UH4l4QensssmbPLwAQ==", + "requires": { + "@babel/runtime": "^7.16.7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -25905,6 +26104,14 @@ "node-modules-regexp": "^1.0.0" } }, + "pkcs7": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", + "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", + "requires": { + "@babel/runtime": "^7.5.5" + } + }, "pkg-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", @@ -26858,8 +27065,7 @@ "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", - "dev": true + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" }, "process-nextick-args": { "version": "2.0.1", @@ -28270,8 +28476,7 @@ "regenerator-runtime": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", - "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==", - "dev": true + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" }, "regenerator-transform": { "version": "0.14.5", @@ -28944,6 +29149,14 @@ "aproba": "^1.1.1" } }, + "rust-result": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz", + "integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==", + "requires": { + "individual": "^2.0.0" + } + }, "rxjs": { "version": "6.5.5", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", @@ -28964,6 +29177,14 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "safe-json-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz", + "integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==", + "requires": { + "rust-result": "^1.0.0" + } + }, "safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -32936,6 +33157,11 @@ "prepend-http": "^2.0.0" } }, + "url-toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", + "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -33118,6 +33344,54 @@ "extsprintf": "^1.2.0" } }, + "video.js": { + "version": "7.21.1", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.21.1.tgz", + "integrity": "sha512-AvHfr14ePDHCfW5Lx35BvXk7oIonxF6VGhSxocmTyqotkQpxwYdmt4tnQSV7MYzNrYHb0GI8tJMt20NDkCQrxg==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/http-streaming": "2.15.1", + "@videojs/vhs-utils": "^3.0.4", + "@videojs/xhr": "2.6.0", + "aes-decrypter": "3.1.3", + "global": "^4.4.0", + "keycode": "^2.2.0", + "m3u8-parser": "4.8.0", + "mpd-parser": "0.22.1", + "mux.js": "6.0.1", + "safe-json-parse": "4.0.0", + "videojs-font": "3.2.0", + "videojs-vtt.js": "^0.15.4" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, + "videojs-font": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz", + "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==" + }, + "videojs-vtt.js": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz", + "integrity": "sha512-r6IhM325fcLb1D6pgsMkTQT1PpFdUdYZa1iqk7wJEu+QlibBwATPfPc9Bg8Jiym0GE5yP1AG2rMLu+QMVWkYtA==", + "requires": { + "global": "^4.3.1" + } + }, "vinyl": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", diff --git a/package.json b/package.json index 61b897323a0..81bf000bb15 100644 --- a/package.json +++ b/package.json @@ -123,9 +123,11 @@ "moment": "2.29.4", "moment-timezone": "0.5.38", "nl.kingsquare.cordova.background-audio": "1.0.1", + "ogv": "1.8.9", "rxjs": "6.5.5", "ts-md5": "1.2.7", "tslib": "2.3.1", + "video.js": "7.21.1", "zone.js": "0.10.3" }, "devDependencies": { From 47e5158afe176987f582f83ece63d856ab4a8774 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 25 Jan 2023 10:34:13 +0100 Subject: [PATCH 30/80] MOBILE-4166 core: Implement CoreDirectivesRegistry and deprecate old one --- .../mod/quiz/pages/player/player.page.ts | 4 +- src/addons/qtype/ddwtos/classes/ddwtos.ts | 4 +- ...{async-component.ts => async-directive.ts} | 6 +- src/core/classes/page-load-watcher.ts | 6 +- src/core/classes/page-loads-manager.ts | 6 +- src/core/classes/tabs.ts | 8 +- .../components/context-menu/context-menu.ts | 4 +- src/core/components/loading/loading.ts | 8 +- .../navbar-buttons/navbar-buttons.ts | 8 +- .../components/tabs-outlet/tabs-outlet.ts | 4 +- src/core/components/tabs/tab.ts | 4 +- src/core/directives/collapsible-footer.ts | 8 +- src/core/directives/collapsible-header.ts | 14 +- src/core/directives/collapsible-item.ts | 6 +- src/core/directives/format-text.ts | 8 +- .../block/classes/base-block-component.ts | 4 +- src/core/features/compile/services/compile.ts | 2 + src/core/features/courses/pages/my/my.ts | 4 +- .../rich-text-editor/rich-text-editor.ts | 4 +- .../question/components/question/question.ts | 8 +- .../components/user-tour/user-tour.ts | 4 +- .../features/usertours/services/user-tours.ts | 4 +- src/core/services/utils/dom.ts | 10 +- src/core/singletons/components-registry.ts | 8 +- src/core/singletons/directives-registry.ts | 145 +++++++++++++++ src/core/singletons/events.ts | 2 +- .../tests/components-registry.test.ts | 104 ----------- .../tests/directives-registry.test.ts | 169 ++++++++++++++++++ src/testing/services/behat-runtime.ts | 4 +- 29 files changed, 392 insertions(+), 178 deletions(-) rename src/core/classes/{async-component.ts => async-directive.ts} (80%) create mode 100644 src/core/singletons/directives-registry.ts delete mode 100644 src/core/singletons/tests/components-registry.test.ts create mode 100644 src/core/singletons/tests/directives-registry.test.ts diff --git a/src/addons/mod/quiz/pages/player/player.page.ts b/src/addons/mod/quiz/pages/player/player.page.ts index 4eb7dd7eabf..42b91c4622b 100644 --- a/src/addons/mod/quiz/pages/player/player.page.ts +++ b/src/addons/mod/quiz/pages/player/player.page.ts @@ -47,7 +47,7 @@ import { CanLeave } from '@guards/can-leave'; import { CoreForms } from '@singletons/form'; import { CoreDom } from '@singletons/dom'; import { CoreTime } from '@singletons/time'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; /** * Page that allows attempting a quiz. @@ -690,7 +690,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave { */ protected async scrollToQuestion(slot: number): Promise { await CoreUtils.nextTick(); - await CoreComponentsRegistry.waitComponentsReady(this.elementRef.nativeElement, 'core-question'); + await CoreDirectivesRegistry.waitDirectivesReady(this.elementRef.nativeElement, 'core-question'); await CoreDom.scrollToElement( this.elementRef.nativeElement, '#addon-mod_quiz-question-' + slot, diff --git a/src/addons/qtype/ddwtos/classes/ddwtos.ts b/src/addons/qtype/ddwtos/classes/ddwtos.ts index 152b38766e7..a06ac29280f 100644 --- a/src/addons/qtype/ddwtos/classes/ddwtos.ts +++ b/src/addons/qtype/ddwtos/classes/ddwtos.ts @@ -15,7 +15,7 @@ import { CoreFormatTextDirective } from '@directives/format-text'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreCoordinates, CoreDom } from '@singletons/dom'; import { CoreEventObserver } from '@singletons/events'; import { CoreLogger } from '@singletons/logger'; @@ -427,7 +427,7 @@ export class AddonQtypeDdwtosQuestion { protected async waitForReady(): Promise { await CoreDom.waitToBeInDOM(this.container); - await CoreComponentsRegistry.waitComponentsReady(this.container, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(this.container, 'core-format-text', CoreFormatTextDirective); const drag = Array.from(this.container.querySelectorAll(this.selectors.dragHomes()))[0]; diff --git a/src/core/classes/async-component.ts b/src/core/classes/async-directive.ts similarity index 80% rename from src/core/classes/async-component.ts rename to src/core/classes/async-directive.ts index 77635a70fd6..b659dc253c1 100644 --- a/src/core/classes/async-component.ts +++ b/src/core/classes/async-directive.ts @@ -13,12 +13,12 @@ // limitations under the License. /** - * Component that is not rendered immediately after being mounted. + * Directive that is not rendered immediately after being mounted. */ -export interface AsyncComponent { +export interface AsyncDirective { /** - * Wait until the component is fully rendered and ready. + * Wait until the directive is fully rendered and ready. */ ready(): Promise; } diff --git a/src/core/classes/page-load-watcher.ts b/src/core/classes/page-load-watcher.ts index 52bba8de700..e062e3b9810 100644 --- a/src/core/classes/page-load-watcher.ts +++ b/src/core/classes/page-load-watcher.ts @@ -15,7 +15,7 @@ import { CoreSitesReadingStrategy } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { Subscription } from 'rxjs'; -import { AsyncComponent } from './async-component'; +import { AsyncDirective } from './async-directive'; import { PageLoadsManager } from './page-loads-manager'; import { CorePromisedValue } from './promised-value'; import { WSObservable } from './site'; @@ -27,7 +27,7 @@ export class PageLoadWatcher { protected hasChanges = false; protected ongoingRequests = 0; - protected components = new Set(); + protected components = new Set(); protected loadedTimeout?: number; protected hasChangesPromises: Promise[] = []; @@ -66,7 +66,7 @@ export class PageLoadWatcher { * * @param component Component instance. */ - async watchComponent(component: AsyncComponent): Promise { + async watchComponent(component: AsyncDirective): Promise { this.components.add(component); clearTimeout(this.loadedTimeout); diff --git a/src/core/classes/page-loads-manager.ts b/src/core/classes/page-loads-manager.ts index 24a28c4a1bd..c3c179ec046 100644 --- a/src/core/classes/page-loads-manager.ts +++ b/src/core/classes/page-loads-manager.ts @@ -16,7 +16,7 @@ import { CoreRefreshButtonModalComponent } from '@components/refresh-button-moda import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { Subject } from 'rxjs'; -import { AsyncComponent } from './async-component'; +import { AsyncDirective } from './async-directive'; import { PageLoadWatcher } from './page-load-watcher'; /** @@ -37,7 +37,7 @@ export class PageLoadsManager { * @param staleWhileRevalidate Whether to use stale while revalidate strategy. * @returns Load watcher to use. */ - startPageLoad(page: AsyncComponent, staleWhileRevalidate: boolean): PageLoadWatcher { + startPageLoad(page: AsyncDirective, staleWhileRevalidate: boolean): PageLoadWatcher { this.initialPath = this.initialPath ?? CoreNavigator.getCurrentPath(); this.currentLoadWatcher = new PageLoadWatcher(this, staleWhileRevalidate); this.ongoingLoadWatchers.add(this.currentLoadWatcher); @@ -53,7 +53,7 @@ export class PageLoadsManager { * @param component Component instance. * @returns Load watcher to use. */ - startComponentLoad(component: AsyncComponent): PageLoadWatcher { + startComponentLoad(component: AsyncDirective): PageLoadWatcher { // If a component is loading data without the page loading data, probably the component is reloading/refreshing. // In that case, create a load watcher instance but don't store it in currentLoadWatcher because it's not a page load. const loadWatcher = this.currentLoadWatcher ?? new PageLoadWatcher(this, false); diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index 5fe244c3a0f..f74babe6822 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -37,8 +37,8 @@ import { CoreDom } from '@singletons/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreError } from './errors/error'; import { CorePromisedValue } from './promised-value'; -import { AsyncComponent } from './async-component'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { AsyncDirective } from './async-directive'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CorePlatform } from '@services/platform'; /** @@ -47,7 +47,7 @@ import { CorePlatform } from '@services/platform'; @Component({ template: '', }) -export class CoreTabsBaseComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy, AsyncComponent { +export class CoreTabsBaseComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy, AsyncDirective { // Minimum tab's width. protected static readonly MIN_TAB_WIDTH = 107; @@ -99,7 +99,7 @@ export class CoreTabsBaseComponent implements OnInit, Aft this.tabAction = new CoreTabsRoleTab(this); - CoreComponentsRegistry.register(element.nativeElement, this); + CoreDirectivesRegistry.register(element.nativeElement, this); } /** diff --git a/src/core/components/context-menu/context-menu.ts b/src/core/components/context-menu/context-menu.ts index 1574075629e..33710b71250 100644 --- a/src/core/components/context-menu/context-menu.ts +++ b/src/core/components/context-menu/context-menu.ts @@ -20,7 +20,7 @@ import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreContextMenuItemComponent } from './context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu-popover'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; /** * This component adds a button (usually in the navigation bar) that displays a context menu popover. @@ -61,7 +61,7 @@ export class CoreContextMenuComponent implements OnInit, OnDestroy { // Calculate the unique ID. this.uniqueId = 'core-context-menu-' + CoreUtils.getUniqueId('CoreContextMenuComponent'); - CoreComponentsRegistry.register(elementRef.nativeElement, this); + CoreDirectivesRegistry.register(elementRef.nativeElement, this); } /** diff --git a/src/core/components/loading/loading.ts b/src/core/components/loading/loading.ts index 6db45e3c8b1..832853f9532 100644 --- a/src/core/components/loading/loading.ts +++ b/src/core/components/loading/loading.ts @@ -18,9 +18,9 @@ import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events'; import { CoreUtils } from '@services/utils/utils'; import { CoreAnimations } from '@components/animations'; import { Translate } from '@singletons'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CorePromisedValue } from '@classes/promised-value'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CoreApp } from '@services/app'; /** @@ -49,7 +49,7 @@ import { CoreApp } from '@services/app'; styleUrls: ['loading.scss'], animations: [CoreAnimations.SHOW_HIDE], }) -export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, AsyncComponent, OnDestroy { +export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, AsyncDirective, OnDestroy { @Input() hideUntil: unknown = false; // Determine when should the contents be shown. @Input() message?: string; // Message to show while loading. @@ -65,7 +65,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, A constructor(element: ElementRef) { this.element = element.nativeElement; - CoreComponentsRegistry.register(this.element, this); + CoreDirectivesRegistry.register(this.element, this); // Calculate the unique ID. this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent'); diff --git a/src/core/components/navbar-buttons/navbar-buttons.ts b/src/core/components/navbar-buttons/navbar-buttons.ts index 83e18fb4765..3cceba4bbb4 100644 --- a/src/core/components/navbar-buttons/navbar-buttons.ts +++ b/src/core/components/navbar-buttons/navbar-buttons.ts @@ -25,7 +25,7 @@ import { import { CoreLogger } from '@singletons/logger'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreContextMenuComponent } from '../context-menu/context-menu'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; const BUTTON_HIDDEN_CLASS = 'core-navbar-button-hidden'; @@ -82,7 +82,7 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { this.element = element.nativeElement; this.logger = CoreLogger.getInstance('CoreNavBarButtonsComponent'); - CoreComponentsRegistry.register(this.element, this); + CoreDirectivesRegistry.register(this.element, this); } /** @@ -156,11 +156,11 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { } const mainContextMenu = buttonsContainer.querySelector('core-context-menu'); - const secondaryContextMenuInstance = CoreComponentsRegistry.resolve(secondaryContextMenu, CoreContextMenuComponent); + const secondaryContextMenuInstance = CoreDirectivesRegistry.resolve(secondaryContextMenu, CoreContextMenuComponent); let mainContextMenuInstance: CoreContextMenuComponent | null; if (mainContextMenu) { // Both containers have a context menu. Merge them to prevent having 2 menus at the same time. - mainContextMenuInstance = CoreComponentsRegistry.resolve(mainContextMenu, CoreContextMenuComponent); + mainContextMenuInstance = CoreDirectivesRegistry.resolve(mainContextMenu, CoreContextMenuComponent); } else { // There is a context-menu in these buttons, but there is no main context menu in the header. // Create one main context menu dynamically. diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts index 93d72b195e3..81f56cb0819 100644 --- a/src/core/components/tabs-outlet/tabs-outlet.ts +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -31,7 +31,7 @@ import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; import { CoreNavigator } from '@services/navigator'; import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; /** * This component displays some top scrollable tabs that will autohide on vertical scroll. @@ -207,7 +207,7 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent { - const instance = CoreComponentsRegistry.resolve(element, CoreNavBarButtonsComponent); + const instance = CoreDirectivesRegistry.resolve(element, CoreNavBarButtonsComponent); if (instance) { const pagetagName = element.closest('.ion-page')?.tagName; diff --git a/src/core/components/tabs/tab.ts b/src/core/components/tabs/tab.ts index 3d9e40561b2..a93de6abc9a 100644 --- a/src/core/components/tabs/tab.ts +++ b/src/core/components/tabs/tab.ts @@ -16,7 +16,7 @@ import { Component, Input, Output, OnInit, OnDestroy, ElementRef, EventEmitter, import { CoreTabBase } from '@classes/tabs'; import { CoreUtils } from '@services/utils/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; import { CoreTabsComponent } from './tabs'; @@ -140,7 +140,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase { protected showHideNavBarButtons(show: boolean): void { const elements = this.element.querySelectorAll('core-navbar-buttons'); elements.forEach((element) => { - const instance = CoreComponentsRegistry.resolve(element, CoreNavBarButtonsComponent); + const instance = CoreDirectivesRegistry.resolve(element, CoreNavBarButtonsComponent); if (instance) { instance.forceHide(!show); diff --git a/src/core/directives/collapsible-footer.ts b/src/core/directives/collapsible-footer.ts index e93fab94809..de58fcedb04 100644 --- a/src/core/directives/collapsible-footer.ts +++ b/src/core/directives/collapsible-footer.ts @@ -17,7 +17,7 @@ import { ScrollDetail } from '@ionic/core'; import { IonContent } from '@ionic/angular'; import { CoreUtils } from '@services/utils/utils'; import { CoreMath } from '@singletons/math'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreFormatTextDirective } from './format-text'; import { CoreEventObserver } from '@singletons/events'; import { CoreLoadingComponent } from '@components/loading/loading'; @@ -203,7 +203,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { * Wait until all children inside the element are done rendering. */ protected async waitFormatTextsRendered(): Promise { - await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-format-text', CoreFormatTextDirective); } /** @@ -249,8 +249,8 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { const scrollElement = await this.ionContent.getScrollElement(); await Promise.all([ - await CoreComponentsRegistry.waitComponentsReady(scrollElement, 'core-loading', CoreLoadingComponent), - await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-loading', CoreLoadingComponent), + await CoreDirectivesRegistry.waitDirectivesReady(scrollElement, 'core-loading', CoreLoadingComponent), + await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-loading', CoreLoadingComponent), ]); } diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index fbd6ff8458d..28d1c7f0971 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -21,7 +21,7 @@ import { CoreTabsComponent } from '@components/tabs/tabs'; import { CoreSettingsHelper } from '@features/settings/services/settings-helper'; import { ScrollDetail } from '@ionic/core'; import { CoreUtils } from '@services/utils/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreMath } from '@singletons/math'; @@ -294,7 +294,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest this.listenEvents(); // Initialize from tabs. - const tabs = CoreComponentsRegistry.resolve(this.page.querySelector('core-tabs-outlet'), CoreTabsOutletComponent); + const tabs = CoreDirectivesRegistry.resolve(this.page.querySelector('core-tabs-outlet'), CoreTabsOutletComponent); if (tabs) { const outlet = tabs.getOutlet(); @@ -424,14 +424,14 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest } // Wait loadings to finish. - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-loading', CoreLoadingComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-loading', CoreLoadingComponent); // Wait tabs to be ready. - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-tabs', CoreTabsComponent); - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-tabs-outlet', CoreTabsOutletComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-tabs', CoreTabsComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-tabs-outlet', CoreTabsOutletComponent); // Wait loadings to finish, inside tabs (if any). - await CoreComponentsRegistry.waitComponentsReady( + await CoreDirectivesRegistry.waitDirectivesReady( this.page, 'core-tab core-loading, ion-router-outlet core-loading', CoreLoadingComponent, @@ -445,7 +445,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest * @returns Promise resolved when texts are rendered. */ protected async waitFormatTextsRendered(element: Element): Promise { - await CoreComponentsRegistry.waitComponentsReady(element, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(element, 'core-format-text', CoreFormatTextDirective); } /** diff --git a/src/core/directives/collapsible-item.ts b/src/core/directives/collapsible-item.ts index 635da33e60c..a845e0a1428 100644 --- a/src/core/directives/collapsible-item.ts +++ b/src/core/directives/collapsible-item.ts @@ -19,7 +19,7 @@ import { CoreSettingsHelper } from '@features/settings/services/settings-helper' import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreColors } from '@singletons/colors'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreEventObserver } from '@singletons/events'; import { Subscription } from 'rxjs'; @@ -128,14 +128,14 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy { return; } - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-loading', CoreLoadingComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-loading', CoreLoadingComponent); } /** * Wait until all children inside the element are done rendering. */ protected async waitFormatTextsRendered(): Promise { - await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-format-text', CoreFormatTextDirective); } /** diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 05e911330f2..4b6be6171f1 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -42,10 +42,10 @@ import { CoreFilter, CoreFilterFilter, CoreFilterFormatTextOptions } from '@feat import { CoreFilterDelegate } from '@features/filter/services/filter-delegate'; import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { CoreSubscriptions } from '@singletons/subscriptions'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreCollapsibleItemDirective } from './collapsible-item'; import { CoreCancellablePromise } from '@classes/cancellable-promise'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CorePath } from '@singletons/path'; import { CoreDom } from '@singletons/dom'; import { CoreEvents } from '@singletons/events'; @@ -67,7 +67,7 @@ import { FrameElementController } from '@classes/element-controllers/FrameElemen @Directive({ selector: 'core-format-text', }) -export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncComponent { +export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirective { @ViewChild(CoreCollapsibleItemDirective) collapsible?: CoreCollapsibleItemDirective; @@ -111,7 +111,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo protected viewContainerRef: ViewContainerRef, @Optional() @Inject(CORE_REFRESH_CONTEXT) protected refreshContext?: CoreRefreshContext, ) { - CoreComponentsRegistry.register(element.nativeElement, this); + CoreDirectivesRegistry.register(element.nativeElement, this); this.element = element.nativeElement; this.element.classList.add('core-loading'); // Hide contents until they're treated. diff --git a/src/core/features/block/classes/base-block-component.ts b/src/core/features/block/classes/base-block-component.ts index 36851922318..c6073cac60f 100644 --- a/src/core/features/block/classes/base-block-component.ts +++ b/src/core/features/block/classes/base-block-component.ts @@ -21,7 +21,7 @@ import { CoreCourseBlock } from '../../course/services/course'; import { Params } from '@angular/router'; import { ContextLevel } from '@/core/constants'; import { CoreNavigationOptions } from '@services/navigator'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CorePromisedValue } from '@classes/promised-value'; /** @@ -30,7 +30,7 @@ import { CorePromisedValue } from '@classes/promised-value'; @Component({ template: '', }) -export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncComponent { +export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncDirective { @Input() title!: string; // The block title. @Input() block!: CoreCourseBlock; // The block to render. diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 51fdcea13bd..0ba9a739c20 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -79,6 +79,7 @@ import { Md5 } from 'ts-md5/dist/md5'; import { CoreSyncBaseProvider } from '@classes/base-sync'; import { CoreArray } from '@singletons/array'; import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreForms } from '@singletons/form'; import { CoreText } from '@singletons/text'; @@ -350,6 +351,7 @@ export class CoreCompileProvider { instance['CoreSyncBaseProvider'] = CoreSyncBaseProvider; instance['CoreArray'] = CoreArray; instance['CoreComponentsRegistry'] = CoreComponentsRegistry; + instance['CoreDirectivesRegistry'] = CoreDirectivesRegistry; instance['CoreNetwork'] = CoreNetwork.instance; instance['CorePlatform'] = CorePlatform.instance; instance['CoreDom'] = CoreDom; diff --git a/src/core/features/courses/pages/my/my.ts b/src/core/features/courses/pages/my/my.ts index ef5ae03a4ca..3a8e8c2b8c7 100644 --- a/src/core/features/courses/pages/my/my.ts +++ b/src/core/features/courses/pages/my/my.ts @@ -14,7 +14,7 @@ import { AddonBlockMyOverviewComponent } from '@addons/block/myoverview/components/myoverview/myoverview'; import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { PageLoadsManager } from '@classes/page-loads-manager'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreBlockComponent } from '@features/block/components/block/block'; @@ -42,7 +42,7 @@ import { CoreCourses } from '../../services/courses'; useClass: PageLoadsManager, }], }) -export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy, AsyncComponent { +export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy, AsyncDirective { @ViewChild(CoreBlockComponent) block!: CoreBlockComponent; diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index 6b0e660c391..5b32415bbbc 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -36,7 +36,7 @@ import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEditorOffline } from '../../services/editor-offline'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreScreen } from '@services/screen'; import { CoreCancellablePromise } from '@classes/cancellable-promise'; @@ -304,7 +304,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit, return; } - await CoreComponentsRegistry.waitComponentsReady(page, 'core-loading', CoreLoadingComponent); + await CoreDirectivesRegistry.waitDirectivesReady(page, 'core-loading', CoreLoadingComponent); } /** diff --git a/src/core/features/question/components/question/question.ts b/src/core/features/question/components/question/question.ts index af9c2d08e4f..9d6d349ef66 100644 --- a/src/core/features/question/components/question/question.ts +++ b/src/core/features/question/components/question/question.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, Input, Output, OnInit, EventEmitter, ChangeDetectorRef, Type, ElementRef } from '@angular/core'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; @@ -22,7 +22,7 @@ import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreLogger } from '@singletons/logger'; /** @@ -33,7 +33,7 @@ import { CoreLogger } from '@singletons/logger'; templateUrl: 'core-question.html', styleUrls: ['../../question.scss'], }) -export class CoreQuestionComponent implements OnInit, AsyncComponent { +export class CoreQuestionComponent implements OnInit, AsyncDirective { @Input() question?: CoreQuestionQuestion; // The question to render. @Input() component?: string; // The component the question belongs to. @@ -66,7 +66,7 @@ export class CoreQuestionComponent implements OnInit, AsyncComponent { constructor(protected changeDetector: ChangeDetectorRef, private element: ElementRef) { this.logger = CoreLogger.getInstance('CoreQuestionComponent'); this.promisedReady = new CorePromisedValue(); - CoreComponentsRegistry.register(this.element.nativeElement, this); + CoreDirectivesRegistry.register(this.element.nativeElement, this); } async ready(): Promise { diff --git a/src/core/features/usertours/components/user-tour/user-tour.ts b/src/core/features/usertours/components/user-tour/user-tour.ts index 5fcb9c2eb46..2f278d8fc23 100644 --- a/src/core/features/usertours/components/user-tour/user-tour.ts +++ b/src/core/features/usertours/components/user-tour/user-tour.ts @@ -30,7 +30,7 @@ import { CoreUserToursPopoverLayout } from '@features/usertours/classes/popover- import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours'; import { CoreDomUtils } from '@services/utils/dom'; import { AngularFrameworkDelegate } from '@singletons'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreMainMenuProvider } from '@features/mainmenu/services/mainmenu'; @@ -84,7 +84,7 @@ export class CoreUserToursUserTourComponent implements AfterViewInit, OnDestroy constructor({ nativeElement: element }: ElementRef) { this.element = element; - CoreComponentsRegistry.register(element, this); + CoreDirectivesRegistry.register(element, this); this.element.addEventListener('click', (event) => this.dismissOnBackOrBackdrop(event.target as HTMLElement)); diff --git a/src/core/features/usertours/services/user-tours.ts b/src/core/features/usertours/services/user-tours.ts index 2c3712d3143..a79b60f934c 100644 --- a/src/core/features/usertours/services/user-tours.ts +++ b/src/core/features/usertours/services/user-tours.ts @@ -21,7 +21,7 @@ import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/da import { CoreApp } from '@services/app'; import { CoreUtils } from '@services/utils/utils'; import { AngularFrameworkDelegate, makeSingleton } from '@singletons'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour'; @@ -120,7 +120,7 @@ export class CoreUserToursService { CoreUserToursUserTourComponent, { ...componentOptions, container }, ); - const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent); + const tour = CoreDirectivesRegistry.require(element, CoreUserToursUserTourComponent); return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus); } diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index bca8e070165..33dad4c1112 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -51,7 +51,7 @@ import { CoreSites } from '@services/sites'; import { NavigationStart } from '@angular/router'; import { filter } from 'rxjs/operators'; import { Subscription } from 'rxjs'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreNetwork } from '@services/network'; import { CoreSiteError } from '@classes/errors/siteerror'; @@ -665,10 +665,10 @@ export class CoreDomUtilsProvider { * * @param element The root element of the component/directive. * @returns The instance, undefined if not found. - * @deprecated since 4.0.0. Use CoreComponentsRegistry instead. + * @deprecated since 4.0.0. Use CoreDirectivesRegistry instead. */ getInstanceByElement(element: Element): T | undefined { - return CoreComponentsRegistry.resolve(element) ?? undefined; + return CoreDirectivesRegistry.resolve(element) ?? undefined; } /** @@ -1718,10 +1718,10 @@ export class CoreDomUtilsProvider { * * @param element The root element of the component/directive. * @param instance The instance to store. - * @deprecated since 4.0.0. Use CoreComponentsRegistry instead. + * @deprecated since 4.0.0. Use CoreDirectivesRegistry instead. */ storeInstanceByElement(element: Element, instance: unknown): void { - CoreComponentsRegistry.register(element, instance); + CoreDirectivesRegistry.register(element, instance); } /** diff --git a/src/core/singletons/components-registry.ts b/src/core/singletons/components-registry.ts index d79e9ca9e5c..cd123802f1f 100644 --- a/src/core/singletons/components-registry.ts +++ b/src/core/singletons/components-registry.ts @@ -13,12 +13,14 @@ // limitations under the License. import { Component } from '@angular/core'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CoreUtils } from '@services/utils/utils'; import { CoreLogger } from './logger'; /** * Registry to keep track of component instances. + * + * @deprecated since 4.1.1. Use CoreDirectivesRegistry instead. */ export class CoreComponentsRegistry { @@ -74,7 +76,7 @@ export class CoreComponentsRegistry { * @param componentClass Component class. * @returns Promise resolved when done. */ - static async waitComponentReady( + static async waitComponentReady( element: Element | null, componentClass?: ComponentConstructor, ): Promise { @@ -96,7 +98,7 @@ export class CoreComponentsRegistry { * @param componentClass Component class. * @returns Promise resolved when done. */ - static async waitComponentsReady( + static async waitComponentsReady( element: Element, selector: string, componentClass?: ComponentConstructor, diff --git a/src/core/singletons/directives-registry.ts b/src/core/singletons/directives-registry.ts new file mode 100644 index 00000000000..a1b17e5b18f --- /dev/null +++ b/src/core/singletons/directives-registry.ts @@ -0,0 +1,145 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Directive } from '@angular/core'; +import { AsyncDirective } from '@classes/async-directive'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLogger } from './logger'; + +/** + * Registry to keep track of directive instances. + */ +export class CoreDirectivesRegistry { + + private static instances: WeakMap = new WeakMap(); + protected static logger = CoreLogger.getInstance('CoreDirectivesRegistry'); + + /** + * Register a directive instance. + * + * @param element Root element. + * @param instance Directive instance. + */ + static register(element: Element, instance: unknown): void { + const list = this.instances.get(element) ?? []; + list.push(instance); + this.instances.set(element, list); + } + + /** + * Resolve a directive instance. + * + * @param element Root element. + * @param directiveClass Directive class. + * @returns Directive instance. + */ + static resolve(element?: Element | null, directiveClass?: DirectiveConstructor): T | null { + const list = (element && this.instances.get(element) as T[]) ?? []; + + return list.find(instance => !directiveClass || instance instanceof directiveClass) ?? null; + } + + /** + * Resolve all directive instances. + * + * @param element Root element. + * @param directiveClass Directive class. + * @returns Directive instances. + */ + static resolveAll(element?: Element | null, directiveClass?: DirectiveConstructor): T[] { + const list = (element && this.instances.get(element) as T[]) ?? []; + + return list.filter(instance => !directiveClass || instance instanceof directiveClass) ?? []; + } + + /** + * Get a directive instance and fail if it cannot be resolved. + * + * @param element Root element. + * @param directiveClass Directive class. + * @returns Directive instance. + */ + static require(element: Element, directiveClass?: DirectiveConstructor): T { + const instance = this.resolve(element, directiveClass); + + if (!instance) { + throw new Error('Couldn\'t resolve directive instance'); + } + + return instance; + } + + /** + * Get a directive instance and wait to be ready. + * + * @param element Root element. + * @param directiveClass Directive class. + * @returns Promise resolved when done. + */ + static async waitDirectiveReady( + element: Element | null, + directiveClass?: DirectiveConstructor, + ): Promise { + const instance = this.resolve(element, directiveClass); + if (!instance) { + this.logger.error('No instance registered for element ' + directiveClass, element); + + return; + } + + await instance.ready(); + } + + /** + * Get all directive instances and wait to be ready. + * + * @param element Root element. + * @param directiveClass Directive class. + * @returns Promise resolved when done. + */ + static async waitDirectivesReady( + element: Element, + selector?: string, + directiveClass?: DirectiveConstructor, + ): Promise { + let elements: Element[] = []; + + if (!selector || element.matches(selector)) { + // Element to wait is myself. + elements = [element]; + } else { + elements = Array.from(element.querySelectorAll(selector)); + } + + if (!elements.length) { + return; + } + + await Promise.all(elements.map(async element => { + const instances = this.resolveAll(element, directiveClass); + + await Promise.all(instances.map(instance => instance.ready())); + })); + + // Wait for next tick to ensure directives are completely rendered. + await CoreUtils.nextTick(); + } + +} + +/** + * Directive constructor. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DirectiveConstructor = { new(...args: any[]): T }; diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index fb1a9c33a73..4f33ad1fa45 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -108,7 +108,7 @@ export class CoreEvents { static readonly FILE_SHARED = 'file_shared'; static readonly KEYBOARD_CHANGE = 'keyboard_change'; /** - * @deprecated since app 4.0. Use CoreComponentsRegistry promises instead. + * @deprecated since app 4.0. Use CoreDirectivesRegistry promises instead. */ static readonly CORE_LOADING_CHANGED = 'core_loading_changed'; static readonly ORIENTATION_CHANGE = 'orientation_change'; diff --git a/src/core/singletons/tests/components-registry.test.ts b/src/core/singletons/tests/components-registry.test.ts deleted file mode 100644 index 4cdeb231a28..00000000000 --- a/src/core/singletons/tests/components-registry.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// (C) Copyright 2015 Moodle Pty Ltd. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { wait } from '@/testing/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; - -const cssClassName = 'core-components-registry-test'; -const createAndRegisterInstance = () => { - const element = document.createElement('div'); - element.classList.add(cssClassName); - const instance = new ComponentsRegistryTestClass(); - - CoreComponentsRegistry.register(element, instance); - - return { element, instance }; -}; - -describe('CoreComponentsRegistry singleton', () => { - - let element: HTMLElement; - let testClassInstance: ComponentsRegistryTestClass; - - beforeEach(() => { - const result = createAndRegisterInstance(); - element = result.element; - testClassInstance = result.instance; - }); - - it('resolves stored instances', () => { - expect(CoreComponentsRegistry.resolve(element)).toEqual(testClassInstance); - expect(CoreComponentsRegistry.resolve(element, ComponentsRegistryTestClass)).toEqual(testClassInstance); - expect(CoreComponentsRegistry.resolve(element, CoreComponentsRegistry)).toEqual(null); - expect(CoreComponentsRegistry.resolve(document.createElement('div'))).toEqual(null); - }); - - it('requires stored instances', () => { - expect(CoreComponentsRegistry.require(element)).toEqual(testClassInstance); - expect(CoreComponentsRegistry.require(element, ComponentsRegistryTestClass)).toEqual(testClassInstance); - expect(() => CoreComponentsRegistry.require(element, CoreComponentsRegistry)).toThrow(); - expect(() => CoreComponentsRegistry.require(document.createElement('div'))).toThrow(); - }); - - it('waits for component ready', async () => { - expect(testClassInstance.isReady).toBe(false); - - await CoreComponentsRegistry.waitComponentReady(element); - - expect(testClassInstance.isReady).toBe(true); - }); - - it('waits for components ready: just one', async () => { - expect(testClassInstance.isReady).toBe(false); - - await CoreComponentsRegistry.waitComponentsReady(element, `.${cssClassName}`); - - expect(testClassInstance.isReady).toBe(true); - }); - - it('waits for components ready: multiple', async () => { - const secondResult = createAndRegisterInstance(); - const thirdResult = createAndRegisterInstance(); - thirdResult.element.classList.remove(cssClassName); // Remove the class so the element and instance aren't treated. - - const parent = document.createElement('div'); - parent.appendChild(element); - parent.appendChild(secondResult.element); - parent.appendChild(thirdResult.element); - - expect(testClassInstance.isReady).toBe(false); - expect(secondResult.instance.isReady).toBe(false); - expect(thirdResult.instance.isReady).toBe(false); - - await CoreComponentsRegistry.waitComponentsReady(parent, `.${cssClassName}`); - - expect(testClassInstance.isReady).toBe(true); - expect(secondResult.instance.isReady).toBe(true); - expect(thirdResult.instance.isReady).toBe(false); - }); - -}); - -class ComponentsRegistryTestClass { - - randomId = Math.random(); - isReady = false; - - async ready(): Promise { - await wait(50); - - this.isReady = true; - } - -} diff --git a/src/core/singletons/tests/directives-registry.test.ts b/src/core/singletons/tests/directives-registry.test.ts new file mode 100644 index 00000000000..c0cbdb82544 --- /dev/null +++ b/src/core/singletons/tests/directives-registry.test.ts @@ -0,0 +1,169 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { wait } from '@/testing/utils'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; + +const cssClassName = 'core-directives-registry-test'; +const createAndRegisterInstance = (element?: HTMLElement) => { + element = element ?? document.createElement('div'); + element.classList.add(cssClassName); + const instance = new DirectivesRegistryTestClass(); + + CoreDirectivesRegistry.register(element, instance); + + return { element, instance }; +}; + +describe('CoreDirectivesRegistry singleton', () => { + + let element: HTMLElement; + let testClassInstance: DirectivesRegistryTestClass; + let testClassSecondInstance: DirectivesRegistryTestClass; + let testAltClassInstance: DirectivesRegistryAltTestClass; + + beforeEach(() => { + let result = createAndRegisterInstance(); + element = result.element; + testClassInstance = result.instance; + + result = createAndRegisterInstance(element); + testClassSecondInstance = result.instance; + + testAltClassInstance = new DirectivesRegistryAltTestClass(); + CoreDirectivesRegistry.register(element, testAltClassInstance); + }); + + it('resolves a stored instance', () => { + expect(CoreDirectivesRegistry.resolve(element)).toEqual(testClassInstance); + expect(CoreDirectivesRegistry.resolve(element, DirectivesRegistryTestClass)).toEqual(testClassInstance); + expect(CoreDirectivesRegistry.resolve(element, DirectivesRegistryAltTestClass)).toEqual(testAltClassInstance); + expect(CoreDirectivesRegistry.resolve(element, CoreDirectivesRegistry)).toEqual(null); + expect(CoreDirectivesRegistry.resolve(document.createElement('div'))).toEqual(null); + }); + + it('resolves all stored instances', () => { + expect(CoreDirectivesRegistry.resolveAll(element)).toEqual( + [testClassInstance, testClassSecondInstance, testAltClassInstance], + ); + expect(CoreDirectivesRegistry.resolveAll(element, DirectivesRegistryTestClass)).toEqual( + [testClassInstance, testClassSecondInstance], + ); + expect(CoreDirectivesRegistry.resolveAll(element, DirectivesRegistryAltTestClass)).toEqual([testAltClassInstance]); + expect(CoreDirectivesRegistry.resolveAll(element, CoreDirectivesRegistry)).toEqual([]); + expect(CoreDirectivesRegistry.resolveAll(document.createElement('div'))).toEqual([]); + }); + + it('requires a stored instance', () => { + expect(CoreDirectivesRegistry.require(element)).toEqual(testClassInstance); + expect(CoreDirectivesRegistry.require(element, DirectivesRegistryTestClass)).toEqual(testClassInstance); + expect(CoreDirectivesRegistry.require(element, DirectivesRegistryAltTestClass)).toEqual(testAltClassInstance); + expect(() => CoreDirectivesRegistry.require(element, CoreDirectivesRegistry)).toThrow(); + expect(() => CoreDirectivesRegistry.require(document.createElement('div'))).toThrow(); + }); + + it('waits for directive ready', async () => { + expect(testClassInstance.isReady).toBe(false); + + await CoreDirectivesRegistry.waitDirectiveReady(element); + + expect(testClassInstance.isReady).toBe(true); + }); + + it('waits for directives ready: just one element and directive', async () => { + const result = createAndRegisterInstance(); + expect(result.instance.isReady).toBe(false); + + await CoreDirectivesRegistry.waitDirectivesReady(result.element, `.${cssClassName}`); + + expect(result.instance.isReady).toBe(true); + expect(testClassInstance.isReady).toBe(false); + }); + + it('waits for directives ready: all directives, single element', async () => { + expect(testClassInstance.isReady).toBe(false); + expect(testClassSecondInstance.isReady).toBe(false); + expect(testAltClassInstance.isReady).toBe(false); + + await CoreDirectivesRegistry.waitDirectivesReady(element); + + expect(testClassInstance.isReady).toBe(true); + expect(testClassSecondInstance.isReady).toBe(true); + expect(testAltClassInstance.isReady).toBe(true); + }); + + it('waits for directives ready: filter by class, single element', async () => { + expect(testClassInstance.isReady).toBe(false); + expect(testClassSecondInstance.isReady).toBe(false); + expect(testAltClassInstance.isReady).toBe(false); + + await CoreDirectivesRegistry.waitDirectivesReady(element, `.${cssClassName}`, DirectivesRegistryTestClass); + + expect(testClassInstance.isReady).toBe(true); + expect(testClassSecondInstance.isReady).toBe(true); + expect(testAltClassInstance.isReady).toBe(false); + }); + + it('waits for directives ready: multiple elements', async () => { + const secondResult = createAndRegisterInstance(); + const thirdResult = createAndRegisterInstance(); + thirdResult.element.classList.remove(cssClassName); // Remove the class so the element and instance aren't treated. + + const parent = document.createElement('div'); + parent.appendChild(element); + parent.appendChild(secondResult.element); + parent.appendChild(thirdResult.element); + + expect(testClassInstance.isReady).toBe(false); + expect(testClassSecondInstance.isReady).toBe(false); + expect(testAltClassInstance.isReady).toBe(false); + expect(secondResult.instance.isReady).toBe(false); + expect(thirdResult.instance.isReady).toBe(false); + + await CoreDirectivesRegistry.waitDirectivesReady(parent, `.${cssClassName}`, DirectivesRegistryTestClass); + + expect(testClassInstance.isReady).toBe(true); + expect(testClassSecondInstance.isReady).toBe(true); + expect(testAltClassInstance.isReady).toBe(false); + expect(secondResult.instance.isReady).toBe(true); + expect(thirdResult.instance.isReady).toBe(false); + }); + +}); + +class DirectivesRegistryTestClass { + + randomId = Math.random(); + isReady = false; + + async ready(): Promise { + await wait(50); + + this.isReady = true; + } + +} + +class DirectivesRegistryAltTestClass { + + randomId = Math.random(); + isReady = false; + + async ready(): Promise { + await wait(50); + + this.isReady = true; + } + +} diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index 0f7eb50527a..4970daaedac 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -23,7 +23,7 @@ import { CoreNetwork, CoreNetworkService } from '@services/network'; import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications'; import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron'; import { CoreLoadingComponent } from '@components/loading/loading'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { Injectable } from '@angular/core'; import { CoreSites, CoreSitesProvider } from '@services/sites'; @@ -127,7 +127,7 @@ export class TestingBehatRuntimeService { .filter((element) => CoreDom.isElementVisible(element)); await Promise.all(elements.map(element => - CoreComponentsRegistry.waitComponentReady(element, CoreLoadingComponent))); + CoreDirectivesRegistry.waitDirectiveReady(element, CoreLoadingComponent))); }); } From 9419db02a16a55e675f36a3843db58c558e8d19f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 27 Jan 2023 12:33:40 +0100 Subject: [PATCH 31/80] MOBILE-4166 core: Use VideoJS in iOS for unsupported media --- scripts/copy-assets.js | 2 + .../mediaplugin/classes/videojs-ogvjs.ts | 762 ++++++++++++++++++ .../filter/mediaplugin/mediaplugin.module.ts | 7 +- .../services/handlers/mediaplugin.ts | 62 +- src/core/directives/external-content.ts | 31 +- src/core/services/app.ts | 18 + src/core/services/utils/mimetype.ts | 10 +- src/core/services/utils/url.ts | 4 +- src/core/singletons/dom.ts | 78 ++ src/index.html | 1 + src/theme/theme.base.scss | 60 ++ 11 files changed, 1012 insertions(+), 23 deletions(-) create mode 100644 src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index 3b3f03ea418..03fec60380e 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -28,6 +28,8 @@ const ASSETS = { '/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML', '/node_modules/mathjax/localization': '/lib/mathjax/localization', '/src/core/features/h5p/assets': '/lib/h5p', + '/node_modules/ogv/dist': '/lib/ogv', + '/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css', }; module.exports = function(ctx) { diff --git a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts new file mode 100644 index 00000000000..19707463a27 --- /dev/null +++ b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts @@ -0,0 +1,762 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CorePlatform } from '@services/platform'; +import { OGVPlayer, OGVCompat, OGVLoader } from 'ogv'; +import videojs from 'video.js'; + +export const Tech = videojs.getComponent('Tech'); + +/** + * Object.defineProperty but "lazy", which means that the value is only set after + * it retrieved the first time, rather than being set right away. + * + * @param obj The object to set the property on. + * @param key The key for the property to set. + * @param getValue The function used to get the value when it is needed. + * @param setter Whether a setter should be allowed or not. + * @returns Object. + */ +const defineLazyProperty = (obj: T, key: string, getValue: () => unknown, setter = true): T => { + const set = (value: unknown): void => { + Object.defineProperty(obj, key, { value, enumerable: true, writable: true }); + }; + + const options: PropertyDescriptor = { + configurable: true, + enumerable: true, + get() { + const value = getValue(); + + set(value); + + return value; + }, + }; + + if (setter) { + options.set = set; + } + + return Object.defineProperty(obj, key, options); +}; + +/** + * OgvJS Media Controller for VideoJS - Wrapper for ogv.js Media API. + * + * Code adapted from https://github.com/HuongNV13/videojs-ogvjs/blob/f9b12bd53018d967bb305f02725834a98f20f61f/src/plugin.js + * Modified in the following ways: + * - Adapted to Typescript. + * - Use our own functions to detect the platform instead of using getDeviceOS. + * - Add an initialize static function. + * - In the play function, reset the media if it already ended to fix problems with replaying media. + * - Allow full screen in iOS devices, and implement enterFullScreen and exitFullScreen to use a fake full screen. + */ +export class VideoJSOgvJS extends Tech { + + /** + * List of available events of the media player. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + static readonly Events = [ + 'loadstart', + 'suspend', + 'abort', + 'error', + 'emptied', + 'stalled', + 'loadedmetadata', + 'loadeddata', + 'canplay', + 'canplaythrough', + 'playing', + 'waiting', + 'seeking', + 'seeked', + 'ended', + 'durationchange', + 'timeupdate', + 'progress', + 'play', + 'pause', + 'ratechange', + 'resize', + 'volumechange', + ]; + + // Variables/functions defined in parent classes. + protected el_!: OGVPlayerEl; // eslint-disable-line @typescript-eslint/naming-convention + protected options_!: VideoJSOptions; // eslint-disable-line @typescript-eslint/naming-convention + protected currentSource_?: TechSourceObject; // eslint-disable-line @typescript-eslint/naming-convention + protected triggerReady!: () => void; + protected on!: (name: string, callback: (e?: Event) => void) => void; + + /** + * Create an instance of this Tech. + * + * @param options The key/value store of player options. + * @param ready Callback function to call when the `OgvJS` Tech is ready. + */ + constructor(options: VideoJSOptions, ready: () => void) { + super(options, ready); + + this.el_.src = options.src || options.source?.src || options.sources?.[0]?.src || this.el_.src; + VideoJSOgvJS.setIfAvailable(this.el_, 'autoplay', options.autoplay); + VideoJSOgvJS.setIfAvailable(this.el_, 'loop', options.loop); + VideoJSOgvJS.setIfAvailable(this.el_, 'poster', options.poster); + VideoJSOgvJS.setIfAvailable(this.el_, 'preload', options.preload); + + this.on('loadedmetadata', () => { + if (CoreApp.isIPhone()) { + // iPhoneOS add some inline styles to the canvas, we need to remove it. + const canvas = this.el_.getElementsByTagName('canvas')[0]; + + canvas.style.removeProperty('width'); + canvas.style.removeProperty('margin'); + } + + this.triggerReady(); + }); + } + + /** + * Set the value for the player is it has that property. + * + * @param el HTML player. + * @param name Name of the property. + * @param value Value to set. + */ + static setIfAvailable(el: HTMLElement, name: string, value: unknown): void { + // eslint-disable-next-line no-prototype-builtins + if (el.hasOwnProperty(name)) { + el[name] = value; + } + }; + + /** + * Check if browser/device is supported by Ogv.JS. + * + * @returns Whether it's supported. + */ + static isSupported(): boolean { + return OGVCompat.supported('OGVPlayer'); + }; + + /** + * Check if the tech can support the given type. + * + * @param type The mimetype to check. + * @returns 'probably', 'maybe', or '' (empty string). + */ + static canPlayType(type: string): string { + return (type.indexOf('/ogg') !== -1 || type.indexOf('/webm')) ? 'maybe' : ''; + }; + + /** + * Check if the tech can support the given source. + * + * @param srcObj The source object. + * @returns The options passed to the tech. + */ + static canPlaySource(srcObj: TechSourceObject): string { + return VideoJSOgvJS.canPlayType(srcObj.type); + }; + + /** + * Check if the volume can be changed in this browser/device. + * Volume cannot be changed in a lot of mobile devices. + * Specifically, it can't be changed from 1 on iOS. + * + * @returns True if volume can be controlled. + */ + static canControlVolume(): boolean { + if (CoreApp.isIPhone()) { + return false; + } + + const player = new OGVPlayer(); + + // eslint-disable-next-line no-prototype-builtins + return player.hasOwnProperty('volume'); + }; + + /** + * Check if the volume can be muted in this browser/device. + * + * @returns True if volume can be muted. + */ + static canMuteVolume(): boolean { + return true; + }; + + /** + * Check if the playback rate can be changed in this browser/device. + * + * @returns True if playback rate can be controlled. + */ + static canControlPlaybackRate(): boolean { + return true; + }; + + /** + * Check to see if native 'TextTracks' are supported by this browser/device. + * + * @returns True if native 'TextTracks' are supported. + */ + static supportsNativeTextTracks(): boolean { + return false; + }; + + /** + * Check if the fullscreen resize is supported by this browser/device. + * + * @returns True if the fullscreen resize is supported. + */ + static supportsFullscreenResize(): boolean { + return true; + }; + + /** + * Check if the progress events is supported by this browser/device. + * + * @returns True if the progress events is supported. + */ + static supportsProgressEvents(): boolean { + return true; + }; + + /** + * Check if the time update events is supported by this browser/device. + * + * @returns True if the time update events is supported. + */ + static supportsTimeupdateEvents(): boolean { + return true; + }; + + /** + * Create the 'OgvJS' Tech's DOM element. + * + * @returns The element that gets created. + */ + createEl(): OGVPlayerEl { + const options = this.options_; + + if (options.base) { + OGVLoader.base = options.base; + } else if (!OGVLoader.base) { + throw new Error('Please specify the base for the ogv.js library'); + } + + const el = new OGVPlayer(options); + + el.className += ' vjs-tech'; + options.tag = el; + + return el; + } + + /** + * Start playback. + */ + play(): void { + if (this.ended()) { + // Reset the player, otherwise the Replay button doesn't work. + this.el_.stop(); + } + + this.el_.play(); + } + + /** + * Get the current playback speed. + * + * @returns Playback speed. + */ + playbackRate(): number { + return this.el_.playbackRate || 1; + } + + /** + * Set the playback speed. + * + * @param val Speed for the player to play. + */ + setPlaybackRate(val: number): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('playbackRate')) { + this.el_.playbackRate = val; + } + } + + /** + * Returns a TimeRanges object that represents the ranges of the media resource that the user agent has played. + * + * @returns The range of points on the media timeline that has been reached through normal playback. + */ + played(): TimeRanges { + return this.el_.played; + } + + /** + * Pause playback. + */ + pause(): void { + this.el_.pause(); + } + + /** + * Is the player paused or not. + * + * @returns Whether is paused. + */ + paused(): boolean { + return this.el_.paused; + } + + /** + * Get current playing time. + * + * @returns Current time. + */ + currentTime(): number { + return this.el_.currentTime; + } + + /** + * Set current playing time. + * + * @param seconds Current time of audio/video. + */ + setCurrentTime(seconds: number): void { + try { + this.el_.currentTime = seconds; + } catch (e) { + videojs.log(e, 'Media is not ready. (Video.JS)'); + } + } + + /** + * Get media's duration. + * + * @returns Duration. + */ + duration(): number { + if (this.el_.duration && this.el_.duration !== Infinity) { + return this.el_.duration; + } + + return 0; + } + + /** + * Get a TimeRange object that represents the intersection + * of the time ranges for which the user agent has all + * relevant media. + * + * @returns Time ranges. + */ + buffered(): TimeRanges { + return this.el_.buffered; + } + + /** + * Get current volume level. + * + * @returns Volume. + */ + volume(): number { + // eslint-disable-next-line no-prototype-builtins + return this.el_.hasOwnProperty('volume') ? this.el_.volume : 1; + } + + /** + * Set current playing volume level. + * + * @param percentAsDecimal Volume percent as a decimal. + */ + setVolume(percentAsDecimal: number): void { + // eslint-disable-next-line no-prototype-builtins + if (!CoreApp.isIPhone() && this.el_.hasOwnProperty('volume')) { + this.el_.volume = percentAsDecimal; + } + } + + /** + * Is the player muted or not. + * + * @returns Whether it's muted. + */ + muted(): boolean { + return this.el_.muted; + } + + /** + * Mute the player. + * + * @param muted True to mute the player. + */ + setMuted(muted: boolean): void { + this.el_.muted = !!muted; + } + + /** + * Is the player muted by default or not. + * + * @returns Whether it's muted by default. + */ + defaultMuted(): boolean { + return this.el_.defaultMuted || false; + } + + /** + * Get the player width. + * + * @returns Width. + */ + width(): number { + return this.el_.offsetWidth; + } + + /** + * Get the player height. + * + * @returns Height. + */ + height(): number { + return this.el_.offsetHeight; + } + + /** + * Get the video width. + * + * @returns Video width. + */ + videoWidth(): number { + return ( this.el_).videoWidth ?? 0; + } + + /** + * Get the video height. + * + * @returns Video heigth. + */ + videoHeight(): number { + return ( this.el_).videoHeight ?? 0; + } + + /** + * Get/set media source. + * + * @param src Source. + * @returns Source when getting it, undefined when setting it. + */ + src(src?: string): string | undefined { + if (typeof src === 'undefined') { + return this.el_.src; + } + + this.el_.src = src; + } + + /** + * Load the media into the player. + */ + load(): void { + this.el_.load(); + } + + /** + * Get current media source. + * + * @returns Current source. + */ + currentSrc(): string { + if (this.currentSource_) { + return this.currentSource_.src; + } + + return this.el_.currentSrc; + } + + /** + * Get media poster URL. + * + * @returns Poster. + */ + poster(): string { + return 'poster' in this.el_ ? this.el_.poster : ''; + } + + /** + * Set media poster URL. + * + * @param url The poster image's url. + */ + setPoster(url: string): void { + ( this.el_).poster = url; + } + + /** + * Is the media preloaded or not. + * + * @returns Whether it's preloaded. + */ + preload(): PreloadOption { + return this.el_.preload || 'none'; + } + + /** + * Set the media preload method. + * + * @param val Value for preload attribute. + */ + setPreload(val: PreloadOption): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('preload')) { + this.el_.preload = val; + } + } + + /** + * Is the media auto-played or not. + * + * @returns Whether it's auto-played. + */ + autoplay(): boolean { + return this.el_.autoplay || false; + } + + /** + * Set media autoplay method. + * + * @param val Value for autoplay attribute. + */ + setAutoplay(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('autoplay')) { + this.el_.autoplay = !!val; + } + } + + /** + * Does the media has controls or not. + * + * @returns Whether it has controls. + */ + controls(): boolean { + return this.el_.controls || false; + } + + /** + * Set the media controls method. + * + * @param val Value for controls attribute. + */ + setControls(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('controls')) { + this.el_.controls = !!val; + } + } + + /** + * Is the media looped or not. + * + * @returns Whether it's looped. + */ + loop(): boolean { + return this.el_.loop || false; + } + + /** + * Set the media loop method. + * + * @param val Value for loop attribute. + */ + setLoop(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('loop')) { + this.el_.loop = !!val; + } + } + + /** + * Get a TimeRanges object that represents the + * ranges of the media resource to which it is possible + * for the user agent to seek. + * + * @returns Time ranges. + */ + seekable(): TimeRanges { + return this.el_.seekable; + } + + /** + * Is player in the "seeking" state or not. + * + * @returns Whether is in the seeking state. + */ + seeking(): boolean { + return this.el_.seeking; + } + + /** + * Is the media ended or not. + * + * @returns Whether it's ended. + */ + ended(): boolean { + return this.el_.ended; + } + + /** + * Get the current state of network activity + * NETWORK_EMPTY (numeric value 0) + * NETWORK_IDLE (numeric value 1) + * NETWORK_LOADING (numeric value 2) + * NETWORK_NO_SOURCE (numeric value 3) + * + * @returns Network state. + */ + networkState(): number { + return this.el_.networkState; + } + + /** + * Get the current state of the player. + * HAVE_NOTHING (numeric value 0) + * HAVE_METADATA (numeric value 1) + * HAVE_CURRENT_DATA (numeric value 2) + * HAVE_FUTURE_DATA (numeric value 3) + * HAVE_ENOUGH_DATA (numeric value 4) + * + * @returns Ready state. + */ + readyState(): number { + return this.el_.readyState; + } + + /** + * Does the player support native fullscreen mode or not. (Mobile devices) + * + * @returns Whether it supports full screen. + */ + supportsFullScreen(): boolean { + // iOS devices have some problem with HTML5 fullscreen api so we need to fallback to fullWindow mode. + return !CoreApp.isIOS(); + } + + /** + * Get media player error. + * + * @returns Error. + */ + error(): MediaError | null { + return this.el_.error; + } + +} + +[ + ['featuresVolumeControl', 'canControlVolume'], + ['featuresMuteControl', 'canMuteVolume'], + ['featuresPlaybackRate', 'canControlPlaybackRate'], + ['featuresNativeTextTracks', 'supportsNativeTextTracks'], + ['featuresFullscreenResize', 'supportsFullscreenResize'], + ['featuresProgressEvents', 'supportsProgressEvents'], + ['featuresTimeupdateEvents', 'supportsTimeupdateEvents'], +].forEach(([key, fn]) => { + defineLazyProperty(VideoJSOgvJS.prototype, key, () => VideoJSOgvJS[fn](), true); +}); +/** + * Initialize the controller. + */ +export const initializeVideoJSOgvJS = (): void => { + OGVLoader.base = 'assets/lib/ogv'; + Tech.registerTech('OgvJS', VideoJSOgvJS); +}; + +export type VideoJSOptions = { + aspectRatio?: string; + audioOnlyMode?: boolean; + audioPosterMode?: boolean; + autoplay?: boolean | string; + autoSetup?: boolean; + base?: string; + breakpoints?: Record; + children?: string[] | Record>; + controlBar?: { + remainingTimeDisplay?: { + displayNegative?: boolean; + }; + }; + controls?: boolean; + fluid?: boolean; + fullscreen?: { + options?: Record; + }; + height?: string | number; + id?: string; + inactivityTimeout?: number; + language?: string; + languages?: Record>; + liveui?: boolean; + liveTracker?: { + trackingThreshold?: number; + liveTolerance?: number; + }; + loop?: boolean; + muted?: boolean; + nativeControlsForTouch?: boolean; + normalizeAutoplay?: boolean; + notSupportedMessage?: string; + noUITitleAttributes?: boolean; + playbackRates?: number[]; + plugins?: Record>; + poster?: string; + preferFullWindow?: boolean; + preload?: PreloadOption; + responsive?: boolean; + restoreEl?: boolean | HTMLElement; + source?: TechSourceObject; + sources?: TechSourceObject[]; + src?: string; + suppressNotSupportedError?: boolean; + tag?: HTMLElement; + techCanOverridePoster?: boolean; + techOrder?: string[]; + userActions?: { + click?: boolean | ((ev: MouseEvent) => void); + doubleClick?: boolean | ((ev: MouseEvent) => void); + hotkeys?: boolean | ((ev: KeyboardEvent) => void) | { + fullscreenKey?: (ev: KeyboardEvent) => void; + muteKey?: (ev: KeyboardEvent) => void; + playPauseKey?: (ev: KeyboardEvent) => void; + }; + }; + 'vtt.js'?: string; + width?: string | number; +}; + +type TechSourceObject = { + src: string; // Source URL. + type: string; // Mimetype. +}; + +type PreloadOption = '' | 'none' | 'metadata' | 'auto'; + +type OGVPlayerEl = (HTMLAudioElement | HTMLVideoElement) & { + stop: () => void; +}; diff --git a/src/addons/filter/mediaplugin/mediaplugin.module.ts b/src/addons/filter/mediaplugin/mediaplugin.module.ts index 1977bd08da1..821b4db73ca 100644 --- a/src/addons/filter/mediaplugin/mediaplugin.module.ts +++ b/src/addons/filter/mediaplugin/mediaplugin.module.ts @@ -15,6 +15,7 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { CoreFilterDelegate } from '@features/filter/services/filter-delegate'; +import { initializeVideoJSOgvJS } from './classes/videojs-ogvjs'; import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin'; @NgModule({ @@ -26,7 +27,11 @@ import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin'; { provide: APP_INITIALIZER, multi: true, - useValue: () => CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance), + useValue: () => { + CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance); + + initializeVideoJSOgvJS(); + }, }, ], }) diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index 50945192719..a54442278d9 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -13,11 +13,17 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreExternalContentDirective } from '@directives/external-content'; import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter'; +import { CoreLang } from '@services/lang'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUrlUtils } from '@services/utils/url'; import { makeSingleton } from '@singletons'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CoreDom } from '@singletons/dom'; +import videojs from 'video.js'; +import { VideoJSOptions } from '../../classes/videojs-ogvjs'; /** * Handler to support the Multimedia filter. @@ -47,6 +53,53 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl return this.template.innerHTML; } + /** + * @inheritdoc + */ + handleHtml(container: HTMLElement): void { + const mediaElements = Array.from(container.querySelectorAll('video, audio')); + + mediaElements.forEach((mediaElement) => { + if (CoreDom.mediaUsesJavascriptPlayer(mediaElement)) { + this.useVideoJS(mediaElement); + } else { + // Remove the VideoJS classes and data if present. + mediaElement.classList.remove('video-js'); + mediaElement.removeAttribute('data-setup'); + mediaElement.removeAttribute('data-setup-lazy'); + } + }); + } + + /** + * Use video JS in a certain video or audio. + * + * @param mediaElement Media element. + */ + protected async useVideoJS(mediaElement: HTMLVideoElement | HTMLAudioElement): Promise { + const lang = await CoreLang.getCurrentLanguage(); + + // Wait for external-content to finish in the element and its sources. + await Promise.all([ + CoreDirectivesRegistry.waitDirectivesReady(mediaElement, undefined, CoreExternalContentDirective), + CoreDirectivesRegistry.waitDirectivesReady(mediaElement, 'source', CoreExternalContentDirective), + ]); + + const dataSetupString = mediaElement.getAttribute('data-setup') || mediaElement.getAttribute('data-setup-lazy') || '{}'; + const data = CoreTextUtils.parseJSON(dataSetupString, {}); + + videojs(mediaElement, { + controls: true, + techOrder: ['OgvJS'], + language: lang, + fluid: true, + controlBar: { + fullscreenToggle: false, + }, + aspectRatio: data.aspectRatio, + }); + } + /** * Treat video filters. Currently only treating youtube video using video JS. * @@ -59,7 +112,7 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl } const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; - const data = CoreTextUtils.parseJSON(dataSetupString, {}); + const data = CoreTextUtils.parseJSON(dataSetupString, {}); const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); if (!youtubeUrl) { @@ -81,10 +134,3 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl } export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService); - -type VideoDataSetup = { - techOrder?: string[]; - sources?: { - src?: string; - }[]; -}; diff --git a/src/core/directives/external-content.ts b/src/core/directives/external-content.ts index 68f0323e443..dcf6977c1c3 100644 --- a/src/core/directives/external-content.ts +++ b/src/core/directives/external-content.ts @@ -36,6 +36,9 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreConstants } from '../constants'; import { CoreNetwork } from '@services/network'; import { Translate } from '@singletons'; +import { AsyncDirective } from '@classes/async-directive'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CorePromisedValue } from '@classes/promised-value'; /** * Directive to handle external content. @@ -50,7 +53,7 @@ import { Translate } from '@singletons'; @Directive({ selector: '[core-external-content]', }) -export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy { +export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy, AsyncDirective { @Input() siteId?: string; // Site ID to use. @Input() component?: string; // Component to link the file to. @@ -67,11 +70,14 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O protected logger: CoreLogger; protected initialized = false; protected fileEventObserver?: CoreEventObserver; + protected onReadyPromise = new CorePromisedValue(); constructor(element: ElementRef) { this.element = element.nativeElement; this.logger = CoreLogger.getInstance('CoreExternalContentDirective'); + + CoreDirectivesRegistry.register(this.element, this); } /** @@ -157,15 +163,21 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O } else { this.invalid = true; + this.onReadyPromise.resolve(); return; } // Avoid handling data url's. if (url && url.indexOf('data:') === 0) { - this.invalid = true; + if (tagName === 'SOURCE') { + // Restoring original src. + this.addSource(url); + } + this.onLoad.emit(); this.loaded = true; + this.onReadyPromise.resolve(); return; } @@ -182,6 +194,8 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O this.loaded = true; } } + } finally { + this.onReadyPromise.resolve(); } } @@ -266,13 +280,11 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O return; } - let urls = inlineStyles.match(/https?:\/\/[^"') ;]*/g); - if (!urls || !urls.length) { + const urls = CoreUtils.uniqueArray(Array.from(inlineStyles.match(/https?:\/\/[^"') ;]*/g) ?? [])); + if (!urls.length) { return; } - urls = CoreUtils.uniqueArray(urls); // Remove duplicates. - const promises = urls.map(async (url) => { const finalUrl = await CoreFilepool.getSrcByUrl(siteId, url, this.component, this.componentId, 0, true, true); @@ -462,4 +474,11 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O this.fileEventObserver?.off(); } + /** + * @inheritdoc + */ + async ready(): Promise { + return this.onReadyPromise; + } + } diff --git a/src/core/services/app.ts b/src/core/services/app.ts index fa63153726c..31bc790fc9b 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -266,6 +266,24 @@ export class CoreAppProvider { return CorePlatform.isMobile() && !CorePlatform.is('android'); } + /** + * Checks if the app is running in an iPad device. + * + * @returns Whether the app is running in an iPad device. + */ + isIPad(): boolean { + return CoreApp.isIOS() && CorePlatform.is('ipad'); + } + + /** + * Checks if the app is running in an iPhone device. + * + * @returns Whether the app is running in an iPhone device. + */ + isIPhone(): boolean { + return CoreApp.isIOS() && CorePlatform.is('iphone'); + } + /** * Check if the keyboard is closing. * diff --git a/src/core/services/utils/mimetype.ts b/src/core/services/utils/mimetype.ts index bf91053626e..a22e98750d5 100644 --- a/src/core/services/utils/mimetype.ts +++ b/src/core/services/utils/mimetype.ts @@ -25,6 +25,7 @@ import { CoreUtils } from '@services/utils/utils'; import extToMime from '@/assets/exttomime.json'; import mimeToExt from '@/assets/mimetoext.json'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; +import { CoreUrl } from '@singletons/url'; interface MimeTypeInfo { type: string; @@ -302,18 +303,13 @@ export class CoreMimetypeUtilsProvider { * @returns The lowercased extension without the dot, or undefined. */ guessExtensionFromUrl(fileUrl: string): string | undefined { - const split = fileUrl.split('.'); + const split = CoreUrl.removeUrlAnchor(fileUrl).split('.'); let extension: string | undefined; if (split.length > 1) { let candidate = split[split.length - 1].toLowerCase(); // Remove params if any. - let position = candidate.indexOf('?'); - if (position > -1) { - candidate = candidate.substring(0, position); - } - // Remove anchor if any. - position = candidate.indexOf('#'); + const position = candidate.indexOf('?'); if (position > -1) { candidate = candidate.substring(0, position); } diff --git a/src/core/services/utils/url.ts b/src/core/services/utils/url.ts index 71ab269037b..aa8a0c9e40d 100644 --- a/src/core/services/utils/url.ts +++ b/src/core/services/utils/url.ts @@ -22,6 +22,7 @@ import { CoreUrl } from '@singletons/url'; import { CoreSites } from '@services/sites'; import { CorePath } from '@singletons/path'; import { CorePlatform } from '@services/platform'; +import { CoreDom } from '@singletons/dom'; /* * "Utils" service with helper functions for URLs. @@ -120,7 +121,8 @@ export class CoreUrlUtilsProvider { // Also, only use it for "core" pluginfile endpoints. Some plugins can implement their own endpoint (like customcert). return !CoreConstants.CONFIG.disableTokenFile && !!accessKey && !url.match(/[&?]file=/) && ( url.indexOf(CorePath.concatenatePaths(siteUrl, 'pluginfile.php')) === 0 || - url.indexOf(CorePath.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0); + url.indexOf(CorePath.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0) && + !CoreDom.sourceUsesJavascriptPlayer({ src: url }); } /** diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 6e6b3d8d8de..08c408d3234 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -13,7 +13,9 @@ // limitations under the License. import { CoreCancellablePromise } from '@classes/cancellable-promise'; +import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver } from '@singletons/events'; @@ -567,6 +569,74 @@ export class CoreDom { } } + /** + * Get all source URLs and types for a video or audio. + * + * @param mediaElement Audio or video element. + * @returns List of sources. + */ + static getMediaSources(mediaElement: HTMLVideoElement | HTMLAudioElement): CoreMediaSource[] { + const sources = Array.from(mediaElement.querySelectorAll('source')).map(source => ({ + src: source.src || source.getAttribute('target-src') || '', + type: source.type, + })); + + if (mediaElement.src) { + sources.push({ + src: mediaElement.src, + type: '', + }); + } + + return sources; + } + + /** + * Check if a source needs to be converted to be able to reproduce it. + * + * @param source Source. + * @returns Whether needs conversion. + */ + static sourceNeedsConversion(source: CoreMediaSource): boolean { + if (!CoreApp.isIOS()) { + return false; + } + + let extension = source.type ? CoreMimetypeUtils.getExtension(source.type) : undefined; + if (!extension) { + extension = CoreMimetypeUtils.guessExtensionFromUrl(source.src); + } + + return !!extension && ['ogv', 'webm', 'oga', 'ogg'].includes(extension); + } + + /** + * Check if JS player should be used for a certain source. + * + * @param source Source. + * @returns Whether JS player should be used. + */ + static sourceUsesJavascriptPlayer(source: CoreMediaSource): boolean { + // For now, only use JS player if the source needs to be converted. + return CoreDom.sourceNeedsConversion(source); + } + + /** + * Check if JS player should be used for a certain audio or video. + * + * @param mediaElement Media element. + * @returns Whether JS player should be used. + */ + static mediaUsesJavascriptPlayer(mediaElement: HTMLVideoElement | HTMLAudioElement): boolean { + if (!CoreApp.isIOS()) { + return false; + } + + const sources = CoreDom.getMediaSources(mediaElement); + + return sources.some(source => CoreDom.sourceUsesJavascriptPlayer(source)); + } + } /** @@ -585,3 +655,11 @@ export type CoreScrollOptions = { addYAxis?: number; addXAxis?: number; }; + +/** + * Source of a media element. + */ +export type CoreMediaSource = { + src: string; + type?: string; +}; diff --git a/src/index.html b/src/index.html index 864f4b89acd..81753381132 100644 --- a/src/index.html +++ b/src/index.html @@ -16,6 +16,7 @@ + diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 26c7fbdd62b..1283e6499cc 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1809,3 +1809,63 @@ ion-modal.core-modal-no-background { display: none; } } + +/** + * VideoJS modifications. + * Styles extracted from the end of https://github.com/moodle/moodle/blob/master/media/player/videojs/styles.css + **/ + +/* Audio: Remove big play button (leave only the button in controls). */ +.video-js.vjs-audio .vjs-big-play-button { + display: none; +} +/* Audio: Make the controlbar visible by default */ +.video-js.vjs-audio .vjs-control-bar { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +/* Make player height minimum to the controls height so when we hide video/poster area the controls are displayed correctly. */ +.video-js.vjs-audio { + min-height: 3em; +} +/* In case of error reset height to the default (otherwise no aspect ratio is available and height becomes 0). */ +.video-js.vjs-error { + height: 150px !important; +} +/* Minimum height for videos should not be less than the size of play button. */ +.mediaplugin_videojs video { + min-height: 32px; +} + +/* MDL-61020: VideoJS timeline progress bar should not be flipped in RTL mode. */ + +/* Prevent the progress bar from being flipped in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-progress-holder .vjs-play-progress, +.video-js .vjs-progress-holder .vjs-load-progress, +.video-js .vjs-progress-holder .vjs-load-progress div { + left: 0; + right: auto; +} +/* Keep the video scrubber button at the end of the progress bar in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-play-progress:before { + left: auto; + right: -0.5em; +} +/* Prevent the volume slider from being flipped in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-volume-level { + left: 0; + right: auto; +} +/* Keep the volume slider handle at the end of the volume slider in RTL. */ +/*rtl:ignore*/ +.vjs-slider-horizontal .vjs-volume-level:before { + left: auto; + right: -0.5em; +} + +/** Finish VideoJS modifications. **/ From 9b011ba3505d513972f121da5c22f64f66bee2e0 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 31 Jan 2023 14:45:31 +0100 Subject: [PATCH 32/80] MOBILE-4166 core: Fix VideoJS in books and destroy players --- .../services/handlers/mediaplugin.ts | 10 +- .../element-controllers/ElementController.ts | 21 ++++ .../MediaElementController.ts | 113 ++++++++++++++++-- src/core/directives/format-text.ts | 2 + src/core/singletons/events.ts | 11 ++ 5 files changed, 143 insertions(+), 14 deletions(-) diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index a54442278d9..0de318e044b 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -22,6 +22,7 @@ import { CoreUrlUtils } from '@services/utils/url'; import { makeSingleton } from '@singletons'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; +import { CoreEvents } from '@singletons/events'; import videojs from 'video.js'; import { VideoJSOptions } from '../../classes/videojs-ogvjs'; @@ -88,7 +89,7 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl const dataSetupString = mediaElement.getAttribute('data-setup') || mediaElement.getAttribute('data-setup-lazy') || '{}'; const data = CoreTextUtils.parseJSON(dataSetupString, {}); - videojs(mediaElement, { + const player = videojs(mediaElement, { controls: true, techOrder: ['OgvJS'], language: lang, @@ -98,6 +99,13 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl }, aspectRatio: data.aspectRatio, }); + + CoreEvents.trigger(CoreEvents.JS_PLAYER_CREATED, { + id: mediaElement.id, + element: mediaElement, + player, + }); + } /** diff --git a/src/core/classes/element-controllers/ElementController.ts b/src/core/classes/element-controllers/ElementController.ts index e4ef1b0004c..d7e889e589e 100644 --- a/src/core/classes/element-controllers/ElementController.ts +++ b/src/core/classes/element-controllers/ElementController.ts @@ -18,6 +18,7 @@ export abstract class ElementController { protected enabled: boolean; + protected destroyed = false; constructor(enabled: boolean) { this.enabled = enabled; @@ -49,6 +50,19 @@ export abstract class ElementController { this.onDisabled(); } + /** + * Destroy the element. + */ + destroy(): void { + if (this.destroyed) { + return; + } + + this.destroyed = true; + + this.onDestroy(); + } + /** * Update underlying element to enable interactivity. */ @@ -59,4 +73,11 @@ export abstract class ElementController { */ abstract onDisabled(): void; + /** + * Destroy/dispose pertinent data. + */ + onDestroy(): void { + // By default, nothing to destroy. + } + } diff --git a/src/core/classes/element-controllers/MediaElementController.ts b/src/core/classes/element-controllers/MediaElementController.ts index 0ae911fc039..1f8b31648b8 100644 --- a/src/core/classes/element-controllers/MediaElementController.ts +++ b/src/core/classes/element-controllers/MediaElementController.ts @@ -14,6 +14,10 @@ import { CoreUtils } from '@services/utils/utils'; import { ElementController } from './ElementController'; +import videojs from 'video.js'; +import { CoreDom } from '@singletons/dom'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; /** * Wrapper class to control the interactivity of a media element. @@ -25,6 +29,10 @@ export class MediaElementController extends ElementController { private playing?: boolean; private playListener?: () => void; private pauseListener?: () => void; + private jsPlayer = new CorePromisedValue(); + private jsPlayerListener?: CoreEventObserver; + private shouldEnable = false; + private shouldDisable = false; constructor(media: HTMLMediaElement, enabled: boolean) { super(enabled); @@ -34,48 +42,127 @@ export class MediaElementController extends ElementController { media.autoplay = false; + if (CoreDom.mediaUsesJavascriptPlayer(media)) { + const player = this.searchJSPlayer(); + if (player) { + this.jsPlayer.resolve(player); + } else { + this.jsPlayerListener = CoreEvents.on(CoreEvents.JS_PLAYER_CREATED, data => { + if (data.element === media) { + this.jsPlayerListener?.off(); + this.jsPlayer.resolve(data.player as VideoJSPlayer); + } + }); + } + } else { + this.jsPlayer.resolve(null); + } + enabled && this.onEnabled(); } /** * @inheritdoc */ - onEnabled(): void { + async onEnabled(): Promise { + this.shouldEnable = true; + this.shouldDisable = false; + + const jsPlayer = await this.jsPlayer; + + if (!this.shouldEnable || this.destroyed) { + return; + } + const ready = this.playing ?? this.autoplay - ? this.media.play() + ? (jsPlayer ?? this.media).play() : Promise.resolve(); - ready - .then(() => this.addPlaybackEventListeners()) - .catch(error => CoreUtils.logUnhandledError('Error enabling media element', error)); + try { + await ready; + + this.addPlaybackEventListeners(jsPlayer); + } catch (error) { + CoreUtils.logUnhandledError('Error enabling media element', error); + } } /** * @inheritdoc */ async onDisabled(): Promise { - this.removePlaybackEventListeners(); + this.shouldDisable = true; + this.shouldEnable = false; - this.media.pause(); + const jsPlayer = await this.jsPlayer; + + if (!this.shouldDisable || this.destroyed) { + return; + } + + this.removePlaybackEventListeners(jsPlayer); + + (jsPlayer ?? this.media).pause(); + } + + /** + * @inheritdoc + */ + async onDestroy(): Promise { + const jsPlayer = await this.jsPlayer; + + this.removePlaybackEventListeners(jsPlayer); + jsPlayer?.dispose(); } /** * Start listening playback events. + * + * @param jsPlayer Javascript player instance (if any). */ - private addPlaybackEventListeners(): void { - this.media.addEventListener('play', this.playListener = () => this.playing = true); - this.media.addEventListener('pause', this.pauseListener = () => this.playing = false); + private addPlaybackEventListeners(jsPlayer: VideoJSPlayer | null): void { + if (jsPlayer) { + jsPlayer.on('play', this.playListener = () => this.playing = true); + jsPlayer.on('pause', this.pauseListener = () => this.playing = false); + } else { + this.media.addEventListener('play', this.playListener = () => this.playing = true); + this.media.addEventListener('pause', this.pauseListener = () => this.playing = false); + } } /** * Stop listening playback events. + * + * @param jsPlayer Javascript player instance (if any). */ - private removePlaybackEventListeners(): void { - this.playListener && this.media.removeEventListener('play', this.playListener); - this.pauseListener && this.media.removeEventListener('pause', this.pauseListener); + private removePlaybackEventListeners(jsPlayer: VideoJSPlayer | null): void { + if (jsPlayer) { + this.playListener && jsPlayer.off('play', this.playListener); + this.pauseListener && jsPlayer.off('pause', this.pauseListener); + } else { + this.playListener && this.media.removeEventListener('play', this.playListener); + this.pauseListener && this.media.removeEventListener('pause', this.pauseListener); + } delete this.playListener; delete this.pauseListener; } + /** + * Search JS player instance. + * + * @returns Player instance if found. + */ + private searchJSPlayer(): VideoJSPlayer | undefined { + return videojs.getPlayer(this.media.id) || videojs.getPlayer(this.media.id.replace('_html5_api', '')); + } + } + +type VideoJSPlayer = { + play: () => Promise; + pause: () => Promise; + on: (name: string, callback: (ev: Event) => void) => void; + off: (name: string, callback: (ev: Event) => void) => void; + dispose: () => void; +}; diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 4b6be6171f1..b747497e64a 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -149,6 +149,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec ngOnDestroy(): void { this.domElementPromise?.cancel(); this.domPromises.forEach((promise) => { promise.cancel();}); + this.elementControllers.forEach(controller => controller.destroy()); } /** @@ -365,6 +366,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec // Move the children to the current element to be able to calculate the height. CoreDomUtils.moveChildren(result.div, this.element); + this.elementControllers.forEach(controller => controller.destroy()); this.elementControllers = result.elementControllers; await CoreUtils.nextTick(); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 4f33ad1fa45..34d949097d9 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -64,6 +64,7 @@ export interface CoreEventsData { [CoreEvents.ORIENTATION_CHANGE]: CoreEventOrientationData; [CoreEvents.COURSE_MODULE_VIEWED]: CoreEventCourseModuleViewed; [CoreEvents.COMPLETE_REQUIRED_PROFILE_DATA_FINISHED]: CoreEventCompleteRequiredProfileDataFinished; + [CoreEvents.JS_PLAYER_CREATED]: CoreEventJSVideoPlayerCreated; } /* @@ -123,6 +124,7 @@ export class CoreEvents { static readonly COMPLETE_REQUIRED_PROFILE_DATA_FINISHED = 'complete_required_profile_data_finished'; static readonly MAIN_HOME_LOADED = 'main_home_loaded'; static readonly FULL_SCREEN_CHANGED = 'full_screen_changed'; + static readonly JS_PLAYER_CREATED = 'js_player_created'; protected static logger = CoreLogger.getInstance('CoreEvents'); protected static observables: { [eventName: string]: Subject } = {}; @@ -490,3 +492,12 @@ export type CoreEventCourseModuleViewed = { export type CoreEventCompleteRequiredProfileDataFinished = { path: string; }; + +/** + * Data passed to JS_PLAYER_CREATED event. + */ +export type CoreEventJSVideoPlayerCreated = { + id: string; + element: HTMLAudioElement | HTMLVideoElement; + player: unknown; +}; From 884827afb6a150438b985530f843daca56f4a713 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 3 Feb 2023 08:55:32 +0100 Subject: [PATCH 33/80] MOBILE-4166 videojs: Support fullscreen and improve types --- .../mediaplugin/classes/videojs-ogvjs.ts | 132 ++++++++---------- .../services/handlers/mediaplugin.ts | 40 ++++-- .../MediaElementController.ts | 18 +-- src/core/services/utils/url.ts | 4 +- src/core/singletons/dom.ts | 4 +- src/core/singletons/events.ts | 3 +- src/core/singletons/media.ts | 104 ++++++++++++++ src/theme/components/videojs.scss | 93 ++++++++++++ src/theme/theme.base.scss | 60 -------- src/theme/theme.scss | 1 + src/types/videojs.d.ts | 115 +++++++++++++++ 11 files changed, 414 insertions(+), 160 deletions(-) create mode 100644 src/core/singletons/media.ts create mode 100644 src/theme/components/videojs.scss create mode 100644 src/types/videojs.d.ts diff --git a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts index 19707463a27..c86ad753188 100644 --- a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts +++ b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts @@ -14,7 +14,7 @@ import { CorePlatform } from '@services/platform'; import { OGVPlayer, OGVCompat, OGVLoader } from 'ogv'; -import videojs from 'video.js'; +import videojs, { PreloadOption, TechSourceObject, VideoJSOptions } from 'video.js'; export const Tech = videojs.getComponent('Tech'); @@ -95,6 +95,10 @@ export class VideoJSOgvJS extends Tech { 'volumechange', ]; + protected playerId?: string; + protected parentElement: HTMLElement | null = null; + protected placeholderElement = document.createElement('div'); + // Variables/functions defined in parent classes. protected el_!: OGVPlayerEl; // eslint-disable-line @typescript-eslint/naming-convention protected options_!: VideoJSOptions; // eslint-disable-line @typescript-eslint/naming-convention @@ -108,7 +112,7 @@ export class VideoJSOgvJS extends Tech { * @param options The key/value store of player options. * @param ready Callback function to call when the `OgvJS` Tech is ready. */ - constructor(options: VideoJSOptions, ready: () => void) { + constructor(options: VideoJSTechOptions, ready: () => void) { super(options, ready); this.el_.src = options.src || options.source?.src || options.sources?.[0]?.src || this.el_.src; @@ -116,6 +120,7 @@ export class VideoJSOgvJS extends Tech { VideoJSOgvJS.setIfAvailable(this.el_, 'loop', options.loop); VideoJSOgvJS.setIfAvailable(this.el_, 'poster', options.poster); VideoJSOgvJS.setIfAvailable(this.el_, 'preload', options.preload); + this.playerId = options.playerId; this.on('loadedmetadata', () => { if (CoreApp.isIPhone()) { @@ -654,8 +659,7 @@ export class VideoJSOgvJS extends Tech { * @returns Whether it supports full screen. */ supportsFullScreen(): boolean { - // iOS devices have some problem with HTML5 fullscreen api so we need to fallback to fullWindow mode. - return !CoreApp.isIOS(); + return !!this.playerId; } /** @@ -667,6 +671,50 @@ export class VideoJSOgvJS extends Tech { return this.el_.error; } + /** + * Enter full screen mode. + */ + enterFullScreen(): void { + // Use a "fake" full screen mode, moving the player to a different place in DOM to be able to use full screen size. + const player = videojs.getPlayer(this.playerId ?? ''); + if (!player) { + return; + } + + const container = player.el(); + this.parentElement = container.parentElement; + if (!this.parentElement) { + // Shouldn't happen, it means the element is not in DOM. Do not support full screen in this case. + return; + } + + this.parentElement.replaceChild(this.placeholderElement, container); + document.body.appendChild(container); + container.classList.add('vjs-ios-moodleapp-fs'); + + player.isFullscreen(true); + } + + /** + * Exit full screen mode. + */ + exitFullScreen(): void { + if (!this.parentElement) { + return; + } + + const player = videojs.getPlayer(this.playerId ?? ''); + if (!player) { + return; + } + + const container = player.el(); + this.parentElement.replaceChild(container, this.placeholderElement); + container.classList.remove('vjs-ios-moodleapp-fs'); + + player.isFullscreen(false); + } + } [ @@ -688,75 +736,13 @@ export const initializeVideoJSOgvJS = (): void => { Tech.registerTech('OgvJS', VideoJSOgvJS); }; -export type VideoJSOptions = { - aspectRatio?: string; - audioOnlyMode?: boolean; - audioPosterMode?: boolean; - autoplay?: boolean | string; - autoSetup?: boolean; - base?: string; - breakpoints?: Record; - children?: string[] | Record>; - controlBar?: { - remainingTimeDisplay?: { - displayNegative?: boolean; - }; - }; - controls?: boolean; - fluid?: boolean; - fullscreen?: { - options?: Record; - }; - height?: string | number; - id?: string; - inactivityTimeout?: number; - language?: string; - languages?: Record>; - liveui?: boolean; - liveTracker?: { - trackingThreshold?: number; - liveTolerance?: number; - }; - loop?: boolean; - muted?: boolean; - nativeControlsForTouch?: boolean; - normalizeAutoplay?: boolean; - notSupportedMessage?: string; - noUITitleAttributes?: boolean; - playbackRates?: number[]; - plugins?: Record>; - poster?: string; - preferFullWindow?: boolean; - preload?: PreloadOption; - responsive?: boolean; - restoreEl?: boolean | HTMLElement; - source?: TechSourceObject; - sources?: TechSourceObject[]; - src?: string; - suppressNotSupportedError?: boolean; - tag?: HTMLElement; - techCanOverridePoster?: boolean; - techOrder?: string[]; - userActions?: { - click?: boolean | ((ev: MouseEvent) => void); - doubleClick?: boolean | ((ev: MouseEvent) => void); - hotkeys?: boolean | ((ev: KeyboardEvent) => void) | { - fullscreenKey?: (ev: KeyboardEvent) => void; - muteKey?: (ev: KeyboardEvent) => void; - playPauseKey?: (ev: KeyboardEvent) => void; - }; - }; - 'vtt.js'?: string; - width?: string | number; -}; - -type TechSourceObject = { - src: string; // Source URL. - type: string; // Mimetype. -}; - -type PreloadOption = '' | 'none' | 'metadata' | 'auto'; - type OGVPlayerEl = (HTMLAudioElement | HTMLVideoElement) & { stop: () => void; }; + +/** + * VideoJS Tech options. It includes some options added by VideoJS internally. + */ +type VideoJSTechOptions = VideoJSOptions & { + playerId?: string; +}; diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index 0de318e044b..5032336207c 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -21,10 +21,9 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreUrlUtils } from '@services/utils/url'; import { makeSingleton } from '@singletons'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; -import { CoreDom } from '@singletons/dom'; import { CoreEvents } from '@singletons/events'; -import videojs from 'video.js'; -import { VideoJSOptions } from '../../classes/videojs-ogvjs'; +import { CoreMedia } from '@singletons/media'; +import videojs, { VideoJSOptions, VideoJSPlayer } from 'video.js'; /** * Handler to support the Multimedia filter. @@ -48,7 +47,7 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl const videos = Array.from(this.template.content.querySelectorAll('video')); videos.forEach((video) => { - this.treatVideoFilters(video); + this.treatYoutubeVideos(video); }); return this.template.innerHTML; @@ -61,7 +60,7 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl const mediaElements = Array.from(container.querySelectorAll('video, audio')); mediaElements.forEach((mediaElement) => { - if (CoreDom.mediaUsesJavascriptPlayer(mediaElement)) { + if (CoreMedia.mediaUsesJavascriptPlayer(mediaElement)) { this.useVideoJS(mediaElement); } else { // Remove the VideoJS classes and data if present. @@ -93,11 +92,14 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl controls: true, techOrder: ['OgvJS'], language: lang, - fluid: true, controlBar: { - fullscreenToggle: false, + pictureInPictureToggle: false, }, aspectRatio: data.aspectRatio, + }, () => { + if (mediaElement.tagName === 'VIDEO') { + this.fixVideoJSPlayerSize(player); + } }); CoreEvents.trigger(CoreEvents.JS_PLAYER_CREATED, { @@ -105,16 +107,34 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl element: mediaElement, player, }); + } + /** + * Fix VideoJS player size. + * If video width is wider than available width, video is cut off. Fix the dimensions in this case. + * + * @param player Player instance. + */ + protected fixVideoJSPlayerSize(player: VideoJSPlayer): void { + const videoWidth = player.videoWidth(); + const videoHeight = player.videoHeight(); + const playerDimensions = player.currentDimensions(); + if (!videoWidth || !videoHeight || !playerDimensions.width || videoWidth === playerDimensions.width) { + return; + } + + const candidateHeight = playerDimensions.width * videoHeight / videoWidth; + if (!playerDimensions.height || Math.abs(candidateHeight - playerDimensions.height) > 1) { + player.dimension('height', candidateHeight); + } } /** - * Treat video filters. Currently only treating youtube video using video JS. + * Treat Video JS Youtube video links and translate them to iframes. * * @param video Video element. */ - protected treatVideoFilters(video: HTMLElement): void { - // Treat Video JS Youtube video links and translate them to iframes. + protected treatYoutubeVideos(video: HTMLElement): void { if (!video.classList.contains('video-js')) { return; } diff --git a/src/core/classes/element-controllers/MediaElementController.ts b/src/core/classes/element-controllers/MediaElementController.ts index 1f8b31648b8..247c3985ef0 100644 --- a/src/core/classes/element-controllers/MediaElementController.ts +++ b/src/core/classes/element-controllers/MediaElementController.ts @@ -14,10 +14,10 @@ import { CoreUtils } from '@services/utils/utils'; import { ElementController } from './ElementController'; -import videojs from 'video.js'; -import { CoreDom } from '@singletons/dom'; +import videojs, { VideoJSPlayer } from 'video.js'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreMedia } from '@singletons/media'; /** * Wrapper class to control the interactivity of a media element. @@ -42,7 +42,7 @@ export class MediaElementController extends ElementController { media.autoplay = false; - if (CoreDom.mediaUsesJavascriptPlayer(media)) { + if (CoreMedia.mediaUsesJavascriptPlayer(media)) { const player = this.searchJSPlayer(); if (player) { this.jsPlayer.resolve(player); @@ -50,7 +50,7 @@ export class MediaElementController extends ElementController { this.jsPlayerListener = CoreEvents.on(CoreEvents.JS_PLAYER_CREATED, data => { if (data.element === media) { this.jsPlayerListener?.off(); - this.jsPlayer.resolve(data.player as VideoJSPlayer); + this.jsPlayer.resolve(data.player); } }); } @@ -153,16 +153,8 @@ export class MediaElementController extends ElementController { * * @returns Player instance if found. */ - private searchJSPlayer(): VideoJSPlayer | undefined { + private searchJSPlayer(): VideoJSPlayer | null { return videojs.getPlayer(this.media.id) || videojs.getPlayer(this.media.id.replace('_html5_api', '')); } } - -type VideoJSPlayer = { - play: () => Promise; - pause: () => Promise; - on: (name: string, callback: (ev: Event) => void) => void; - off: (name: string, callback: (ev: Event) => void) => void; - dispose: () => void; -}; diff --git a/src/core/services/utils/url.ts b/src/core/services/utils/url.ts index aa8a0c9e40d..c7c63dec402 100644 --- a/src/core/services/utils/url.ts +++ b/src/core/services/utils/url.ts @@ -22,7 +22,7 @@ import { CoreUrl } from '@singletons/url'; import { CoreSites } from '@services/sites'; import { CorePath } from '@singletons/path'; import { CorePlatform } from '@services/platform'; -import { CoreDom } from '@singletons/dom'; +import { CoreMedia } from '@singletons/media'; /* * "Utils" service with helper functions for URLs. @@ -122,7 +122,7 @@ export class CoreUrlUtilsProvider { return !CoreConstants.CONFIG.disableTokenFile && !!accessKey && !url.match(/[&?]file=/) && ( url.indexOf(CorePath.concatenatePaths(siteUrl, 'pluginfile.php')) === 0 || url.indexOf(CorePath.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0) && - !CoreDom.sourceUsesJavascriptPlayer({ src: url }); + !CoreMedia.sourceUsesJavascriptPlayer({ src: url }); } /** diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 08c408d3234..bfcbae0b1ec 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -15,7 +15,6 @@ import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver } from '@singletons/events'; @@ -569,6 +568,7 @@ export class CoreDom { } } +<<<<<<< HEAD /** * Get all source URLs and types for a video or audio. * @@ -637,6 +637,8 @@ export class CoreDom { return sources.some(source => CoreDom.sourceUsesJavascriptPlayer(source)); } +======= +>>>>>>> f42ea632ca (a) } /** diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 34d949097d9..10be74a3eba 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -20,6 +20,7 @@ import { CoreFilepoolComponentFileEventData } from '@services/filepool'; import { CoreRedirectPayload } from '@services/navigator'; import { CoreCourseModuleCompletionData } from '@features/course/services/course-helper'; import { CoreScreenOrientation } from '@services/screen'; +import { VideoJSPlayer } from 'video.js'; /** * Observer instance to stop listening to an event. @@ -499,5 +500,5 @@ export type CoreEventCompleteRequiredProfileDataFinished = { export type CoreEventJSVideoPlayerCreated = { id: string; element: HTMLAudioElement | HTMLVideoElement; - player: unknown; + player: VideoJSPlayer; }; diff --git a/src/core/singletons/media.ts b/src/core/singletons/media.ts new file mode 100644 index 00000000000..cdf487cac9c --- /dev/null +++ b/src/core/singletons/media.ts @@ -0,0 +1,104 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CorePlatform } from '@services/platform'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; + +/** + * Singleton with helper functions for media. + */ +export class CoreMedia { + + // Avoid creating singleton instances. + private constructor() { + // Nothing to do. + } + + /** + * Get all source URLs and types for a video or audio. + * + * @param mediaElement Audio or video element. + * @returns List of sources. + */ + static getMediaSources(mediaElement: HTMLVideoElement | HTMLAudioElement): CoreMediaSource[] { + const sources = Array.from(mediaElement.querySelectorAll('source')).map(source => ({ + src: source.src || source.getAttribute('target-src') || '', + type: source.type, + })); + + if (mediaElement.src) { + sources.push({ + src: mediaElement.src, + type: '', + }); + } + + return sources; + } + + /** + * Check if a source needs to be converted to be able to reproduce it. + * + * @param source Source. + * @returns Whether needs conversion. + */ + static sourceNeedsConversion(source: CoreMediaSource): boolean { + if (!CorePlatform.isIOS()) { + return false; + } + + let extension = source.type ? CoreMimetypeUtils.getExtension(source.type) : undefined; + if (!extension) { + extension = CoreMimetypeUtils.guessExtensionFromUrl(source.src); + } + + return !!extension && ['ogv', 'webm', 'oga', 'ogg'].includes(extension); + } + + /** + * Check if JS player should be used for a certain source. + * + * @param source Source. + * @returns Whether JS player should be used. + */ + static sourceUsesJavascriptPlayer(source: CoreMediaSource): boolean { + // For now, only use JS player if the source needs to be converted. + return CoreMedia.sourceNeedsConversion(source); + } + + /** + * Check if JS player should be used for a certain audio or video. + * + * @param mediaElement Media element. + * @returns Whether JS player should be used. + */ + static mediaUsesJavascriptPlayer(mediaElement: HTMLVideoElement | HTMLAudioElement): boolean { + if (!CorePlatform.isIOS()) { + return false; + } + + const sources = CoreMedia.getMediaSources(mediaElement); + + return sources.some(source => CoreMedia.sourceUsesJavascriptPlayer(source)); + } + +} + +/** + * Source of a media element. + */ +export type CoreMediaSource = { + src: string; + type?: string; +}; diff --git a/src/theme/components/videojs.scss b/src/theme/components/videojs.scss new file mode 100644 index 00000000000..13dd8f6a855 --- /dev/null +++ b/src/theme/components/videojs.scss @@ -0,0 +1,93 @@ +/** + * VideoJS modifications. + **/ + +/** + * App specific modifications. + **/ + +// In case of error reset height to the default (otherwise no aspect ratio is available and height becomes 0). +.video-js.vjs-error { + height: 150px !important; +} + +// Fake full screen mode. +.vjs-ios-moodleapp-fs { + z-index: 100000 !important; + + canvas { + max-width: 100%; + max-height: 100%; + left: 0px; + right: 0px; + margin-left: auto; + margin-right: auto; + padding-bottom: var(--ion-safe-area-bottom, 0px); + } + + .vjs-control-bar { + height: calc(3em + var(--ion-safe-area-bottom, 0px)); + + .vjs-progress-control { + padding-bottom: var(--ion-safe-area-bottom, 0px); + } + } +} + +/** + * Styles extracted from: + * https://github.com/moodle/moodle/blob/3c5423d8c0bae003e6eb20119ca657c0c6760309/media/player/videojs/styles.css#L1773 + **/ + +/* Audio: Remove big play button (leave only the button in controls). */ +.video-js.vjs-audio .vjs-big-play-button { + display: none; +} +/* Audio: Make the controlbar visible by default */ +.video-js.vjs-audio .vjs-control-bar { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +/* Make player height minimum to the controls height so when we hide video/poster area the controls are displayed correctly. */ +.video-js.vjs-audio { + min-height: 3em; +} +/* In case of error reset height to the default (otherwise no aspect ratio is available and height becomes 0). */ +.video-js.vjs-error { + height: 150px; +} +/* Minimum height for videos should not be less than the size of play button. */ +.mediaplugin_videojs video { + min-height: 32px; +} + +/* MDL-61020: VideoJS timeline progress bar should not be flipped in RTL mode. */ + +/* Prevent the progress bar from being flipped in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-progress-holder .vjs-play-progress, +.video-js .vjs-progress-holder .vjs-load-progress, +.video-js .vjs-progress-holder .vjs-load-progress div { + left: 0; + right: auto; +} +/* Keep the video scrubber button at the end of the progress bar in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-play-progress:before { + left: auto; + right: -0.5em; +} +/* Prevent the volume slider from being flipped in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-volume-level { + left: 0; + right: auto; +} +/* Keep the volume slider handle at the end of the volume slider in RTL. */ +/*rtl:ignore*/ +.vjs-slider-horizontal .vjs-volume-level:before { + left: auto; + right: -0.5em; +} diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 1283e6499cc..26c7fbdd62b 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1809,63 +1809,3 @@ ion-modal.core-modal-no-background { display: none; } } - -/** - * VideoJS modifications. - * Styles extracted from the end of https://github.com/moodle/moodle/blob/master/media/player/videojs/styles.css - **/ - -/* Audio: Remove big play button (leave only the button in controls). */ -.video-js.vjs-audio .vjs-big-play-button { - display: none; -} -/* Audio: Make the controlbar visible by default */ -.video-js.vjs-audio .vjs-control-bar { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} -/* Make player height minimum to the controls height so when we hide video/poster area the controls are displayed correctly. */ -.video-js.vjs-audio { - min-height: 3em; -} -/* In case of error reset height to the default (otherwise no aspect ratio is available and height becomes 0). */ -.video-js.vjs-error { - height: 150px !important; -} -/* Minimum height for videos should not be less than the size of play button. */ -.mediaplugin_videojs video { - min-height: 32px; -} - -/* MDL-61020: VideoJS timeline progress bar should not be flipped in RTL mode. */ - -/* Prevent the progress bar from being flipped in RTL. */ -/*rtl:ignore*/ -.video-js .vjs-progress-holder .vjs-play-progress, -.video-js .vjs-progress-holder .vjs-load-progress, -.video-js .vjs-progress-holder .vjs-load-progress div { - left: 0; - right: auto; -} -/* Keep the video scrubber button at the end of the progress bar in RTL. */ -/*rtl:ignore*/ -.video-js .vjs-play-progress:before { - left: auto; - right: -0.5em; -} -/* Prevent the volume slider from being flipped in RTL. */ -/*rtl:ignore*/ -.video-js .vjs-volume-level { - left: 0; - right: auto; -} -/* Keep the volume slider handle at the end of the volume slider in RTL. */ -/*rtl:ignore*/ -.vjs-slider-horizontal .vjs-volume-level:before { - left: auto; - right: -0.5em; -} - -/** Finish VideoJS modifications. **/ diff --git a/src/theme/theme.scss b/src/theme/theme.scss index c13ffdfd5ab..5baa55e422f 100644 --- a/src/theme/theme.scss +++ b/src/theme/theme.scss @@ -26,6 +26,7 @@ @import "./components/rubrics.scss"; @import "./components/mod-label.scss"; @import "../core/components/error-info/error-info.scss"; +@import "./components/videojs.scss"; /* Some styles from 3rd party libraries. */ @import "./bootstrap.scss"; diff --git a/src/types/videojs.d.ts b/src/types/videojs.d.ts new file mode 100644 index 00000000000..53ab829c2a6 --- /dev/null +++ b/src/types/videojs.d.ts @@ -0,0 +1,115 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +declare module 'video.js' { + function videojs( + elementOrId: string | HTMLElement, + options?: VideoJSOptions, + readyCallback?: () => void, + ): VideoJSPlayer; + + namespace videojs { + function getPlayer(id: string): VideoJSPlayer | null; + function log(...args): void; + function getComponent(name: string): any; // eslint-disable-line @typescript-eslint/no-explicit-any + } + + export default videojs; + + export type VideoJSPlayer = { + play: () => Promise; + pause: () => Promise; + on: (name: string, callback: (ev: Event) => void) => void; + off: (name: string, callback: (ev: Event) => void) => void; + dispose: () => void; + el: () => HTMLElement; + fluid: (val?: boolean) => void | boolean; + isFullscreen: (val?: boolean) => void | boolean; + videoHeight: () => number; + videoWidth: () => number; + currentDimensions: () => { width: number; height: number }; + dimension: (dimension: string, value: number) => void; + }; + + export type VideoJSOptions = { + aspectRatio?: string; + audioOnlyMode?: boolean; + audioPosterMode?: boolean; + autoplay?: boolean | string; + autoSetup?: boolean; + base?: string; + breakpoints?: Record; + children?: string[] | Record>; + controlBar?: { + fullscreenToggle?: boolean; + pictureInPictureToggle?: boolean; + remainingTimeDisplay?: { + displayNegative?: boolean; + }; + }; + controls?: boolean; + fluid?: boolean; + fullscreen?: { + options?: Record; + }; + height?: string | number; + id?: string; + inactivityTimeout?: number; + language?: string; + languages?: Record>; + liveui?: boolean; + liveTracker?: { + trackingThreshold?: number; + liveTolerance?: number; + }; + loop?: boolean; + muted?: boolean; + nativeControlsForTouch?: boolean; + normalizeAutoplay?: boolean; + notSupportedMessage?: string; + noUITitleAttributes?: boolean; + playbackRates?: number[]; + plugins?: Record>; + poster?: string; + preferFullWindow?: boolean; + preload?: PreloadOption; + responsive?: boolean; + restoreEl?: boolean | HTMLElement; + source?: TechSourceObject; + sources?: TechSourceObject[]; + src?: string; + suppressNotSupportedError?: boolean; + tag?: HTMLElement; + techCanOverridePoster?: boolean; + techOrder?: string[]; + userActions?: { + click?: boolean | ((ev: MouseEvent) => void); + doubleClick?: boolean | ((ev: MouseEvent) => void); + hotkeys?: boolean | ((ev: KeyboardEvent) => void) | { + fullscreenKey?: (ev: KeyboardEvent) => void; + muteKey?: (ev: KeyboardEvent) => void; + playPauseKey?: (ev: KeyboardEvent) => void; + }; + }; + 'vtt.js'?: string; + width?: string | number; + }; + + export type TechSourceObject = { + src: string; // Source URL. + type: string; // Mimetype. + }; + + export type PreloadOption = '' | 'none' | 'metadata' | 'auto'; +} From 2258c1183b45f61e8ea81efedb7e963bb558f043 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 9 Feb 2023 10:33:33 +0100 Subject: [PATCH 34/80] MOBILE-4166 core: Move some CoreApp functions to CorePlatform --- .../mediaplugin/classes/videojs-ogvjs.ts | 6 +- .../data/fields/latlong/component/latlong.ts | 4 +- .../mod/resource/components/index/index.ts | 4 +- src/core/components/file/file.ts | 4 +- src/core/components/loading/loading.ts | 4 +- src/core/components/local-file/local-file.ts | 4 +- .../components/show-password/show-password.ts | 4 +- src/core/directives/external-content.ts | 4 +- .../services/fileuploader-helper.ts | 3 +- .../fileuploader/services/fileuploader.ts | 6 +- .../fileuploader/services/handlers/audio.ts | 4 +- .../fileuploader/services/handlers/file.ts | 5 +- .../fileuploader/services/handlers/video.ts | 4 +- .../login/pages/credentials/credentials.ts | 3 +- src/core/features/login/pages/site/site.ts | 3 +- src/core/features/mainmenu/pages/menu/menu.ts | 4 +- .../features/mainmenu/services/mainmenu.ts | 6 +- .../services/pushnotifications.ts | 4 +- .../classes/settings-sections-source.ts | 4 +- .../settings/pages/deviceinfo/deviceinfo.ts | 5 +- .../settings/pages/general/general.ts | 4 +- .../sharedfiles/services/handlers/settings.ts | 4 +- .../sharedfiles/services/handlers/upload.ts | 4 +- .../services/sharedfiles-helper.ts | 3 +- .../siteplugins/services/siteplugins.ts | 2 +- src/core/initializers/inject-ios-scripts.ts | 6 +- .../initializers/prepare-inapp-browser.ts | 7 +- src/core/services/app.ts | 37 +++------- src/core/services/file-helper.ts | 4 +- src/core/services/file.ts | 11 ++- src/core/services/geolocation.ts | 4 +- src/core/services/local-notifications.ts | 6 +- src/core/services/platform.ts | 51 ++++++++++++- src/core/services/tests/utils/text.test.ts | 8 +-- src/core/services/utils/dom.ts | 11 +-- src/core/services/utils/iframe.ts | 8 +-- src/core/services/utils/text.ts | 4 +- src/core/services/utils/utils.ts | 11 ++- src/core/singletons/dom.ts | 72 ------------------- src/testing/utils.ts | 2 + 40 files changed, 153 insertions(+), 191 deletions(-) diff --git a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts index c86ad753188..16fc96e1943 100644 --- a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts +++ b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts @@ -123,7 +123,7 @@ export class VideoJSOgvJS extends Tech { this.playerId = options.playerId; this.on('loadedmetadata', () => { - if (CoreApp.isIPhone()) { + if (CorePlatform.isIPhone()) { // iPhoneOS add some inline styles to the canvas, we need to remove it. const canvas = this.el_.getElementsByTagName('canvas')[0]; @@ -186,7 +186,7 @@ export class VideoJSOgvJS extends Tech { * @returns True if volume can be controlled. */ static canControlVolume(): boolean { - if (CoreApp.isIPhone()) { + if (CorePlatform.isIPhone()) { return false; } @@ -393,7 +393,7 @@ export class VideoJSOgvJS extends Tech { */ setVolume(percentAsDecimal: number): void { // eslint-disable-next-line no-prototype-builtins - if (!CoreApp.isIPhone() && this.el_.hasOwnProperty('volume')) { + if (!CorePlatform.isIPhone() && this.el_.hasOwnProperty('volume')) { this.el_.volume = percentAsDecimal; } } diff --git a/src/addons/mod/data/fields/latlong/component/latlong.ts b/src/addons/mod/data/fields/latlong/component/latlong.ts index 38398beae73..17c7a4f18fd 100644 --- a/src/addons/mod/data/fields/latlong/component/latlong.ts +++ b/src/addons/mod/data/fields/latlong/component/latlong.ts @@ -18,8 +18,8 @@ import { Component } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { SafeUrl } from '@angular/platform-browser'; import { CoreAnyError } from '@classes/errors/error'; -import { CoreApp } from '@services/app'; import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation'; +import { CorePlatform } from '@services/platform'; import { CoreDomUtils } from '@services/utils/dom'; import { DomSanitizer } from '@singletons'; @@ -73,7 +73,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginBa const northFixed = north ? north.toFixed(4) : '0.0000'; const eastFixed = east ? east.toFixed(4) : '0.0000'; - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { url = 'http://maps.apple.com/?ll=' + northFixed + ',' + eastFixed + '&near=' + northFixed + ',' + eastFixed; } else { url = 'geo:' + northFixed + ',' + eastFixed; diff --git a/src/addons/mod/resource/components/index/index.ts b/src/addons/mod/resource/components/index/index.ts index 545082efc01..1fb0ecfebef 100644 --- a/src/addons/mod/resource/components/index/index.ts +++ b/src/addons/mod/resource/components/index/index.ts @@ -19,7 +19,6 @@ import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourse } from '@features/course/services/course'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFileHelper } from '@services/file-helper'; import { CoreSites } from '@services/sites'; @@ -35,6 +34,7 @@ import { AddonModResourceProvider, } from '../../services/resource'; import { AddonModResourceHelper } from '../../services/resource-helper'; +import { CorePlatform } from '@services/platform'; /** * Component that displays a resource. @@ -79,7 +79,7 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource async ngOnInit(): Promise { super.ngOnInit(); - this.isIOS = CoreApp.isIOS(); + this.isIOS = CorePlatform.isIOS(); this.isOnline = CoreNetwork.isOnline(); // Refresh online status when changes. diff --git a/src/core/components/file/file.ts b/src/core/components/file/file.ts index 12f2d2fb2cb..d15e5e9e410 100644 --- a/src/core/components/file/file.ts +++ b/src/core/components/file/file.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, Input, Output, OnInit, OnDestroy, EventEmitter } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFilepool } from '@services/filepool'; import { CoreFileHelper } from '@services/file-helper'; @@ -27,6 +26,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreConstants } from '@/core/constants'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreWSFile } from '@services/ws'; +import { CorePlatform } from '@services/platform'; /** * Component to handle a remote file. Shows the file name, icon (depending on mimetype) and a button @@ -87,7 +87,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { this.fileSize = this.file.filesize; this.fileName = this.file.filename || ''; - this.isIOS = CoreApp.isIOS(); + this.isIOS = CorePlatform.isIOS(); this.defaultIsOpenWithPicker = CoreFileHelper.defaultIsOpenWithPicker(); this.openButtonIcon = this.defaultIsOpenWithPicker ? 'fas-file' : 'fas-share-square'; this.openButtonLabel = this.defaultIsOpenWithPicker ? 'core.openfile' : 'core.openwith'; diff --git a/src/core/components/loading/loading.ts b/src/core/components/loading/loading.ts index 832853f9532..df08396dcf7 100644 --- a/src/core/components/loading/loading.ts +++ b/src/core/components/loading/loading.ts @@ -21,7 +21,7 @@ import { Translate } from '@singletons'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CorePromisedValue } from '@classes/promised-value'; import { AsyncDirective } from '@classes/async-directive'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; /** * Component to show a loading spinner and message while data is being loaded. @@ -146,7 +146,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, A if (loaded) { this.onReadyPromise.resolve(); this.restoreScrollPosition(); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { this.mutationObserver.observe(this.element, { childList: true }); } } else { diff --git a/src/core/components/local-file/local-file.ts b/src/core/components/local-file/local-file.ts index 8301fbad399..62de411c772 100644 --- a/src/core/components/local-file/local-file.ts +++ b/src/core/components/local-file/local-file.ts @@ -25,8 +25,8 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils, CoreUtilsOpenFileOptions, OpenFileAction } from '@services/utils/utils'; import { CoreForms } from '@singletons/form'; -import { CoreApp } from '@services/app'; import { CorePath } from '@singletons/path'; +import { CorePlatform } from '@services/platform'; /** * Component to handle a local file. Only files inside the app folder can be managed. @@ -83,7 +83,7 @@ export class CoreLocalFileComponent implements OnInit { this.timemodified = CoreTimeUtils.userDate(metadata.modificationTime.getTime(), 'core.strftimedatetimeshort'); - this.isIOS = CoreApp.isIOS(); + this.isIOS = CorePlatform.isIOS(); this.defaultIsOpenWithPicker = CoreFileHelper.defaultIsOpenWithPicker(); this.openButtonIcon = this.defaultIsOpenWithPicker ? 'fas-file' : 'fas-share-square'; this.openButtonLabel = this.defaultIsOpenWithPicker ? 'core.openfile' : 'core.openwith'; diff --git a/src/core/components/show-password/show-password.ts b/src/core/components/show-password/show-password.ts index 12289e0d74c..3b61044bf1a 100644 --- a/src/core/components/show-password/show-password.ts +++ b/src/core/components/show-password/show-password.ts @@ -15,7 +15,7 @@ import { Component, OnInit, AfterViewInit, Input, ElementRef, ContentChild } from '@angular/core'; import { IonInput } from '@ionic/angular'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; @@ -121,7 +121,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { this.setData(this.input); // In Android, the keyboard is closed when the input type changes. Focus it again. - if (isFocused && CoreApp.isAndroid()) { + if (isFocused && CorePlatform.isAndroid()) { CoreDomUtils.focusElement(this.input); } } diff --git a/src/core/directives/external-content.ts b/src/core/directives/external-content.ts index dcf6977c1c3..a4cd4afa73f 100644 --- a/src/core/directives/external-content.ts +++ b/src/core/directives/external-content.ts @@ -23,7 +23,6 @@ import { EventEmitter, OnDestroy, } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreFile } from '@services/file'; import { CoreFilepool, CoreFilepoolFileActions, CoreFilepoolFileEventData } from '@services/filepool'; import { CoreSites } from '@services/sites'; @@ -39,6 +38,7 @@ import { Translate } from '@singletons'; import { AsyncDirective } from '@classes/async-directive'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CorePromisedValue } from '@classes/promised-value'; +import { CorePlatform } from '@services/platform'; /** * Directive to handle external content. @@ -117,7 +117,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O newSource.setAttribute('src', url); if (type) { - if (CoreApp.isAndroid() && type == 'video/quicktime') { + if (CorePlatform.isAndroid() && type == 'video/quicktime') { // Fix for VideoJS/Chrome bug https://github.com/videojs/video.js/issues/423 . newSource.setAttribute('type', 'video/mp4'); } else { diff --git a/src/core/features/fileuploader/services/fileuploader-helper.ts b/src/core/features/fileuploader/services/fileuploader-helper.ts index 30fe5ebf155..47b649a28d1 100644 --- a/src/core/features/fileuploader/services/fileuploader-helper.ts +++ b/src/core/features/fileuploader/services/fileuploader-helper.ts @@ -19,7 +19,6 @@ import { ChooserResult } from '@ionic-native/chooser/ngx'; import { FileEntry, IFile } from '@ionic-native/file/ngx'; import { MediaFile } from '@ionic-native/media-capture/ngx'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFile, CoreFileProvider, CoreFileProgressEvent } from '@services/file'; import { CoreDomUtils } from '@services/utils/dom'; @@ -652,7 +651,7 @@ export class CoreFileUploaderHelperProvider { options.mediaType = Camera.MediaType.PICTURE; } else if (!imageSupported && videoSupported) { options.mediaType = Camera.MediaType.VIDEO; - } else if (CoreApp.isIOS()) { + } else if (CorePlatform.isIOS()) { // Only get all media in iOS because in Android using this option allows uploading any kind of file. options.mediaType = Camera.MediaType.ALLMEDIA; } diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts index 4949c64ff43..f40ee749010 100644 --- a/src/core/features/fileuploader/services/fileuploader.ts +++ b/src/core/features/fileuploader/services/fileuploader.ts @@ -18,7 +18,6 @@ import { FileEntry } from '@ionic-native/file/ngx'; import { MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; import { Subject } from 'rxjs'; -import { CoreApp } from '@services/app'; import { CoreFile, CoreFileProvider } from '@services/file'; import { CoreFilepool } from '@services/filepool'; import { CoreSites } from '@services/sites'; @@ -33,6 +32,7 @@ import { CoreError } from '@classes/errors/error'; import { CoreSite } from '@classes/site'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; import { CorePath } from '@singletons/path'; +import { CorePlatform } from '@services/platform'; /** * File upload options. @@ -236,7 +236,7 @@ export class CoreFileUploaderProvider { getCameraUploadOptions(uri: string, isFromAlbum?: boolean): CoreFileUploaderOptions { const extension = CoreMimetypeUtils.guessExtensionFromUrl(uri); const mimetype = CoreMimetypeUtils.getMimeType(extension); - const isIOS = CoreApp.isIOS(); + const isIOS = CorePlatform.isIOS(); const options: CoreFileUploaderOptions = { deleteAfterUpload: !isFromAlbum, mimeType: mimetype, @@ -259,7 +259,7 @@ export class CoreFileUploaderProvider { // If the file was picked from the album, delete it only if it was copied to the app's folder. options.deleteAfterUpload = CoreFile.isFileInAppFolder(uri); - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { // Picking an image from album in Android adds a timestamp at the end of the file. Delete it. options.fileName = options.fileName.replace(/(\.[^.]*)\?[^.]*$/, '$1'); } diff --git a/src/core/features/fileuploader/services/handlers/audio.ts b/src/core/features/fileuploader/services/handlers/audio.ts index 99c4030ed7f..cdce9f086ac 100644 --- a/src/core/features/fileuploader/services/handlers/audio.ts +++ b/src/core/features/fileuploader/services/handlers/audio.ts @@ -45,10 +45,10 @@ export class CoreFileUploaderAudioHandlerService implements CoreFileUploaderHand * @returns Supported mimetypes. */ getSupportedMimetypes(mimetypes: string[]): string[] { - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS it's recorded as WAV. return CoreUtils.filterByRegexp(mimetypes, /^audio\/wav$/); - } else if (CoreApp.isAndroid()) { + } else if (CorePlatform.isAndroid()) { // In Android we don't know the format the audio will be recorded, so accept any audio mimetype. return CoreUtils.filterByRegexp(mimetypes, /^audio\//); } else { diff --git a/src/core/features/fileuploader/services/handlers/file.ts b/src/core/features/fileuploader/services/handlers/file.ts index 54407e38796..0ed301042f5 100644 --- a/src/core/features/fileuploader/services/handlers/file.ts +++ b/src/core/features/fileuploader/services/handlers/file.ts @@ -14,7 +14,6 @@ import { Injectable } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader-delegate'; import { CoreFileUploaderHelper } from '../fileuploader-helper'; @@ -94,7 +93,7 @@ export class CoreFileUploaderFileHandlerService implements CoreFileUploaderHandl const input = document.createElement('input'); input.setAttribute('type', 'file'); input.classList.add('core-fileuploader-file-handler-input'); - if (mimetypes && mimetypes.length && (!CoreApp.isAndroid() || mimetypes.length == 1)) { + if (mimetypes && mimetypes.length && (!CorePlatform.isAndroid() || mimetypes.length == 1)) { // Don't use accept attribute in Android with several mimetypes, it's not supported. input.setAttribute('accept', mimetypes.join(', ')); } @@ -134,7 +133,7 @@ export class CoreFileUploaderFileHandlerService implements CoreFileUploaderHandl } }); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS, the click on the input stopped working for some reason. We need to put it 1 level higher. element.parentElement?.appendChild(input); diff --git a/src/core/features/fileuploader/services/handlers/video.ts b/src/core/features/fileuploader/services/handlers/video.ts index b22f0750f87..0472f22485e 100644 --- a/src/core/features/fileuploader/services/handlers/video.ts +++ b/src/core/features/fileuploader/services/handlers/video.ts @@ -45,10 +45,10 @@ export class CoreFileUploaderVideoHandlerService implements CoreFileUploaderHand * @returns Supported mimetypes. */ getSupportedMimetypes(mimetypes: string[]): string[] { - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS it's recorded as MOV. return CoreUtils.filterByRegexp(mimetypes, /^video\/quicktime$/); - } else if (CoreApp.isAndroid()) { + } else if (CorePlatform.isAndroid()) { // In Android we don't know the format the video will be recorded, so accept any video mimetype. return CoreUtils.filterByRegexp(mimetypes, /^video\//); } else { diff --git a/src/core/features/login/pages/credentials/credentials.ts b/src/core/features/login/pages/credentials/credentials.ts index c5c69bfc35c..5841c1e7e33 100644 --- a/src/core/features/login/pages/credentials/credentials.ts +++ b/src/core/features/login/pages/credentials/credentials.ts @@ -32,6 +32,7 @@ import { CoreUserSupport } from '@features/user/services/support'; import { CoreUserSupportConfig } from '@features/user/classes/support/support-config'; import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config'; import { SafeHtml } from '@angular/platform-browser'; +import { CorePlatform } from '@services/platform'; /** * Page to enter the user credentials. @@ -108,7 +109,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { this.pageLoaded = true; } - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // Make iOS auto-fill work. The field that isn't focused doesn't get updated, do it manually. // Debounce it to prevent triggering this function too often when the user is typing. this.valueChangeSubscription = this.credForm.valueChanges.pipe(debounceTime(1000)).subscribe((changes) => { diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 7673fa6fc7c..4fb73cadf86 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -45,6 +45,7 @@ import { CoreErrorInfoComponent } from '@components/error-info/error-info'; import { CoreUserSupportConfig } from '@features/user/classes/support/support-config'; import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config'; import { CoreLoginError } from '@classes/errors/loginerror'; +import { CorePlatform } from '@services/platform'; /** * Site (url) chooser when adding a new site. @@ -93,7 +94,7 @@ export class CoreLoginSitePage implements OnInit { // Load fixed sites if they're set. if (CoreLoginHelper.hasSeveralFixedSites()) { url = this.initSiteSelector(); - } else if (CoreConstants.CONFIG.enableonboarding && !CoreApp.isIOS()) { + } else if (CoreConstants.CONFIG.enableonboarding && !CorePlatform.isIOS()) { this.initOnboarding(); } diff --git a/src/core/features/mainmenu/pages/menu/menu.ts b/src/core/features/mainmenu/pages/menu/menu.ts index 39211bd1b6f..f75066f04bd 100644 --- a/src/core/features/mainmenu/pages/menu/menu.ts +++ b/src/core/features/mainmenu/pages/menu/menu.ts @@ -17,7 +17,6 @@ import { IonTabs } from '@ionic/angular'; import { BackButtonEvent } from '@ionic/core'; import { Subscription } from 'rxjs'; -import { CoreApp } from '@services/app'; import { CoreEvents, CoreEventObserver } from '@singletons/events'; import { CoreMainMenu, CoreMainMenuProvider } from '../../services/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate'; @@ -31,6 +30,7 @@ import { trigger, state, style, transition, animate } from '@angular/animations' import { CoreSites } from '@services/sites'; import { CoreDom } from '@singletons/dom'; import { CoreLogger } from '@singletons/logger'; +import { CorePlatform } from '@services/platform'; const ANIMATION_DURATION = 500; @@ -135,7 +135,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { }); document.addEventListener('ionBackButton', this.backButtonFunction); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS, the resize event is triggered before the keyboard is opened/closed and not triggered again once done. // Init handlers again once keyboard is closed since the resize event doesn't have the updated height. this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, (kbHeight: number) => { diff --git a/src/core/features/mainmenu/services/mainmenu.ts b/src/core/features/mainmenu/services/mainmenu.ts index 39cd39869dd..583d6b0dacd 100644 --- a/src/core/features/mainmenu/services/mainmenu.ts +++ b/src/core/features/mainmenu/services/mainmenu.ts @@ -14,7 +14,6 @@ import { Injectable } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreLang, CoreLangLanguage } from '@services/lang'; import { CoreSites } from '@services/sites'; import { CoreConstants } from '@/core/constants'; @@ -23,6 +22,7 @@ import { Device, makeSingleton } from '@singletons'; import { CoreArray } from '@singletons/array'; import { CoreTextUtils } from '@services/utils/text'; import { CoreScreen } from '@services/screen'; +import { CorePlatform } from '@services/platform'; declare module '@singletons/events' { @@ -196,9 +196,9 @@ export class CoreMainMenuProvider { osversion: Device.version, }; - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { replacements.devicetype = 'Android'; - } else if (CoreApp.isIOS()) { + } else if (CorePlatform.isIOS()) { replacements.devicetype = 'iPhone or iPad'; } else { replacements.devicetype = 'Other'; diff --git a/src/core/features/pushnotifications/services/pushnotifications.ts b/src/core/features/pushnotifications/services/pushnotifications.ts index 583f8145106..417ee38499b 100644 --- a/src/core/features/pushnotifications/services/pushnotifications.ts +++ b/src/core/features/pushnotifications/services/pushnotifications.ts @@ -228,7 +228,7 @@ export class CorePushNotificationsProvider { * @returns Promise resolved when done. */ protected async createDefaultChannel(): Promise { - if (!CoreApp.isAndroid()) { + if (!CorePlatform.isAndroid()) { return; } @@ -481,7 +481,7 @@ export class CorePushNotificationsProvider { text: notification.message, channel: 'PushPluginChannel', }; - const isAndroid = CoreApp.isAndroid(); + const isAndroid = CorePlatform.isAndroid(); const extraFeatures = CoreUtils.isTrueOrOne(data.extrafeatures); if (extraFeatures && isAndroid && CoreUtils.isFalseOrZero(data.notif)) { diff --git a/src/core/features/settings/classes/settings-sections-source.ts b/src/core/features/settings/classes/settings-sections-source.ts index d3bcd02c997..caf7554880b 100644 --- a/src/core/features/settings/classes/settings-sections-source.ts +++ b/src/core/features/settings/classes/settings-sections-source.ts @@ -16,7 +16,7 @@ import { CoreConstants } from '@/core/constants'; import { Params } from '@angular/router'; import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { SHAREDFILES_PAGE_NAME } from '@features/sharedfiles/sharedfiles.module'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; /** * Provides a collection of setting sections. @@ -45,7 +45,7 @@ export class CoreSettingsSectionsSource extends CoreRoutedItemsManagerSource { - return CoreApp.isIOS(); + return CorePlatform.isIOS(); } /** diff --git a/src/core/features/sharedfiles/services/handlers/upload.ts b/src/core/features/sharedfiles/services/handlers/upload.ts index a32da22fd81..c0ff3719599 100644 --- a/src/core/features/sharedfiles/services/handlers/upload.ts +++ b/src/core/features/sharedfiles/services/handlers/upload.ts @@ -19,7 +19,7 @@ import { CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult, } from '@features/fileuploader/services/fileuploader-delegate'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; import { makeSingleton } from '@singletons'; import { CoreSharedFilesHelper } from '../sharedfiles-helper'; /** @@ -37,7 +37,7 @@ export class CoreSharedFilesUploadHandlerService implements CoreFileUploaderHand * @returns True or promise resolved with true if enabled. */ async isEnabled(): Promise { - return CoreApp.isIOS(); + return CorePlatform.isIOS(); } /** diff --git a/src/core/features/sharedfiles/services/sharedfiles-helper.ts b/src/core/features/sharedfiles/services/sharedfiles-helper.ts index c3ab8d4c3fb..4fa26d913a5 100644 --- a/src/core/features/sharedfiles/services/sharedfiles-helper.ts +++ b/src/core/features/sharedfiles/services/sharedfiles-helper.ts @@ -18,7 +18,6 @@ import { FileEntry } from '@ionic-native/file/ngx'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreFileUploaderHandlerResult } from '@features/fileuploader/services/fileuploader-delegate'; -import { CoreApp } from '@services/app'; import { CoreFile } from '@services/file'; import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; @@ -49,7 +48,7 @@ export class CoreSharedFilesHelperProvider { * Initialize. */ initialize(): void { - if (!CoreApp.isIOS()) { + if (!CorePlatform.isIOS()) { return; } diff --git a/src/core/features/siteplugins/services/siteplugins.ts b/src/core/features/siteplugins/services/siteplugins.ts index 6cc006cd43f..77247e9b797 100644 --- a/src/core/features/siteplugins/services/siteplugins.ts +++ b/src/core/features/siteplugins/services/siteplugins.ts @@ -97,7 +97,7 @@ export class CoreSitePluginsProvider { }; if (args.appismobile) { - defaultArgs.appplatform = CoreApp.isIOS() ? 'ios' : 'android'; + defaultArgs.appplatform = CorePlatform.isIOS() ? 'ios' : 'android'; } return { diff --git a/src/core/initializers/inject-ios-scripts.ts b/src/core/initializers/inject-ios-scripts.ts index eac4cb3b667..36affed3fac 100644 --- a/src/core/initializers/inject-ios-scripts.ts +++ b/src/core/initializers/inject-ios-scripts.ts @@ -12,14 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreApp } from '@services/app'; import { CorePlatform } from '@services/platform'; import { CoreIframeUtils } from '@services/utils/iframe'; +/** + * Inject some scripts for iOS iframes. + */ export default async function(): Promise { await CorePlatform.ready(); - if (!CoreApp.isIOS() || !('WKUserScript' in window)) { + if (!CorePlatform.isIOS() || !('WKUserScript' in window)) { return; } diff --git a/src/core/initializers/prepare-inapp-browser.ts b/src/core/initializers/prepare-inapp-browser.ts index 026e6115cc5..18be057f631 100644 --- a/src/core/initializers/prepare-inapp-browser.ts +++ b/src/core/initializers/prepare-inapp-browser.ts @@ -16,7 +16,7 @@ import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config'; import { CoreUserNullSupportConfig } from '@features/user/classes/support/null-support-config'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; import { CoreSites } from '@services/sites'; import { CoreCustomURLSchemes } from '@services/urlschemes'; import { CoreDomUtils } from '@services/utils/dom'; @@ -27,6 +27,9 @@ import { CoreEvents } from '@singletons/events'; let lastInAppUrl: string | null = null; +/** + * + */ export default function(): void { // Check URLs loaded in any InAppBrowser. CoreEvents.on(CoreEvents.IAB_LOAD_START, async (event) => { @@ -59,7 +62,7 @@ export default function(): void { return; } - if (!CoreApp.isAndroid()) { + if (!CorePlatform.isAndroid()) { return; } diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 31bc790fc9b..56263b14365 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -18,7 +18,7 @@ import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; -import { makeSingleton, Keyboard, StatusBar, Device } from '@singletons'; +import { makeSingleton, Keyboard, StatusBar } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreColors } from '@singletons/colors'; import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/database/app'; @@ -204,7 +204,7 @@ export class CoreAppProvider { return 'itms-apps://itunes.apple.com/app/' + storesConfig.ios; } - if (this.isAndroid() && storesConfig.android) { + if (CorePlatform.isAndroid() && storesConfig.android) { return 'market://details?id=' + storesConfig.android; } @@ -219,13 +219,10 @@ export class CoreAppProvider { * Get platform major version number. * * @returns The platform major number. + * @deprecated since 4.1.1. Use CorePlatform.getPlatformMajorVersion instead. */ getPlatformMajorVersion(): number { - if (!CorePlatform.isMobile()) { - return 0; - } - - return Number(Device.version?.split('.')[0]); + return CorePlatform.getPlatformMajorVersion(); } /** @@ -242,9 +239,10 @@ export class CoreAppProvider { * Checks if the app is running in an Android mobile or tablet device. * * @returns Whether the app is running in an Android mobile or tablet device. + * @deprecated since 4.1.1. Use CorePlatform.isAndroid instead. */ isAndroid(): boolean { - return CorePlatform.isMobile() && CorePlatform.is('android'); + return CorePlatform.isAndroid(); } /** @@ -261,27 +259,10 @@ export class CoreAppProvider { * Checks if the app is running in an iOS mobile or tablet device. * * @returns Whether the app is running in an iOS mobile or tablet device. + * @deprecated since 4.1.1. Use CorePlatform.isIOS instead. */ isIOS(): boolean { - return CorePlatform.isMobile() && !CorePlatform.is('android'); - } - - /** - * Checks if the app is running in an iPad device. - * - * @returns Whether the app is running in an iPad device. - */ - isIPad(): boolean { - return CoreApp.isIOS() && CorePlatform.is('ipad'); - } - - /** - * Checks if the app is running in an iPhone device. - * - * @returns Whether the app is running in an iPhone device. - */ - isIPhone(): boolean { - return CoreApp.isIOS() && CorePlatform.is('iphone'); + return CorePlatform.isIOS(); } /** @@ -405,7 +386,7 @@ export class CoreAppProvider { */ openKeyboard(): void { // Open keyboard is not supported in desktop and in iOS. - if (this.isAndroid()) { + if (CorePlatform.isAndroid()) { Keyboard.show(); } } diff --git a/src/core/services/file-helper.ts b/src/core/services/file-helper.ts index 0d6404f0161..93825ee38c2 100644 --- a/src/core/services/file-helper.ts +++ b/src/core/services/file-helper.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import { FileEntry } from '@ionic-native/file/ngx'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFile } from '@services/file'; import { CoreFilepool } from '@services/filepool'; @@ -31,6 +30,7 @@ import { CoreNetworkError } from '@classes/errors/network-error'; import { CoreConfig } from './config'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CorePlatform } from './platform'; /** * Provider to provide some helper functions regarding files and packages. @@ -44,7 +44,7 @@ export class CoreFileHelperProvider { * @returns Boolean. */ defaultIsOpenWithPicker(): boolean { - return CoreApp.isIOS() && CoreConstants.CONFIG.iOSDefaultOpenFileAction === OpenFileAction.OPEN_WITH; + return CorePlatform.isIOS() && CoreConstants.CONFIG.iOSDefaultOpenFileAction === OpenFileAction.OPEN_WITH; } /** diff --git a/src/core/services/file.ts b/src/core/services/file.ts index f03ac2e6516..cc45ae0d26e 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -16,7 +16,6 @@ import { Injectable } from '@angular/core'; import { FileEntry, DirectoryEntry, Entry, Metadata, IFile } from '@ionic-native/file/ngx'; -import { CoreApp } from '@services/app'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; @@ -140,9 +139,9 @@ export class CoreFileProvider { await CorePlatform.ready(); - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { this.basePath = File.externalApplicationStorageDirectory || this.basePath; - } else if (CoreApp.isIOS()) { + } else if (CorePlatform.isIOS()) { this.basePath = File.documentsDirectory || this.basePath; } else if (!this.isAvailable() || this.basePath === '') { this.logger.error('Error getting device OS.'); @@ -441,7 +440,7 @@ export class CoreFileProvider { */ calculateFreeSpace(): Promise { return File.getFreeDiskSpace().then((size) => { - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS the size is in bytes. return Number(size); } @@ -717,7 +716,7 @@ export class CoreFileProvider { async getBasePathToDownload(): Promise { await this.init(); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS we want the internal URL (cdvfile://localhost/persistent/...). const dirEntry = await File.resolveDirectoryUrl(this.basePath); @@ -1263,7 +1262,7 @@ export class CoreFileProvider { return src; } - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { return src.replace(CoreConstants.CONFIG.ioswebviewscheme + '://localhost/_app_file_', 'file://'); } diff --git a/src/core/services/geolocation.ts b/src/core/services/geolocation.ts index bea6a4df5a8..e736cdba8f0 100644 --- a/src/core/services/geolocation.ts +++ b/src/core/services/geolocation.ts @@ -74,7 +74,7 @@ export class CoreGeolocationProvider { return; } - if (!CoreApp.isIOS()) { + if (!CorePlatform.isIOS()) { Diagnostic.switchToLocationSettings(); await CoreApp.waitForResume(30000); @@ -142,7 +142,7 @@ export class CoreGeolocationProvider { * Request and return the location authorization status for the application. */ protected async requestLocationAuthorization(): Promise { - if (!CoreApp.isIOS()) { + if (!CorePlatform.isIOS()) { await Diagnostic.requestLocationAuthorization(); return; diff --git a/src/core/services/local-notifications.ts b/src/core/services/local-notifications.ts index 7b9bc100a12..2b3e9d82066 100644 --- a/src/core/services/local-notifications.ts +++ b/src/core/services/local-notifications.ts @@ -186,7 +186,7 @@ export class CoreLocalNotificationsProvider { */ canDisableSound(): boolean { // Only allow disabling sound in Android 7 or lower. In iOS and Android 8+ it can easily be done with system settings. - return this.isAvailable() && CoreApp.isAndroid() && CoreApp.getPlatformMajorVersion() < 8; + return CorePlatform.isAndroid() && CorePlatform.getPlatformMajorVersion() < 8; } /** @@ -195,7 +195,7 @@ export class CoreLocalNotificationsProvider { * @returns Promise resolved when done. */ protected async createDefaultChannel(): Promise { - if (!CoreApp.isAndroid()) { + if (!CorePlatform.isAndroid()) { return; } @@ -583,7 +583,7 @@ export class CoreLocalNotificationsProvider { notification.data.component = component; notification.data.siteId = siteId; - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { notification.icon = notification.icon || 'res://icon'; notification.smallIcon = notification.smallIcon || 'res://smallicon'; notification.color = notification.color || CoreConstants.CONFIG.notificoncolor; diff --git a/src/core/services/platform.ts b/src/core/services/platform.ts index bb157d9f6f2..c00787d1bd1 100644 --- a/src/core/services/platform.ts +++ b/src/core/services/platform.ts @@ -14,7 +14,7 @@ import { Injectable } from '@angular/core'; import { Platform } from '@ionic/angular'; -import { makeSingleton } from '@singletons'; +import { Device, makeSingleton } from '@singletons'; /** * Extend Ionic's Platform service. @@ -22,6 +22,55 @@ import { makeSingleton } from '@singletons'; @Injectable({ providedIn: 'root' }) export class CorePlatformService extends Platform { + /** + * Get platform major version number. + * + * @returns The platform major number. + */ + getPlatformMajorVersion(): number { + if (!this.isMobile()) { + return 0; + } + + return Number(Device.version?.split('.')[0]); + } + + /** + * Checks if the app is running in an Android mobile or tablet device. + * + * @returns Whether the app is running in an Android mobile or tablet device. + */ + isAndroid(): boolean { + return this.isMobile() && this.is('android'); + } + + /** + * Checks if the app is running in an iOS mobile or tablet device. + * + * @returns Whether the app is running in an iOS mobile or tablet device. + */ + isIOS(): boolean { + return this.isMobile() && !this.is('android'); + } + + /** + * Checks if the app is running in an iPad device. + * + * @returns Whether the app is running in an iPad device. + */ + isIPad(): boolean { + return this.isIOS() && this.is('ipad'); + } + + /** + * Checks if the app is running in an iPhone device. + * + * @returns Whether the app is running in an iPhone device. + */ + isIPhone(): boolean { + return this.isIOS() && this.is('iphone'); + } + /** * Checks if the app is running in a mobile or tablet device (Cordova). * diff --git a/src/core/services/tests/utils/text.test.ts b/src/core/services/tests/utils/text.test.ts index 6d11a105035..67db6765082 100644 --- a/src/core/services/tests/utils/text.test.ts +++ b/src/core/services/tests/utils/text.test.ts @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreApp } from '@services/app'; import { CoreTextUtilsProvider } from '@services/utils/text'; import { DomSanitizer } from '@singletons'; import { mockSingleton } from '@/testing/utils'; +import { CorePlatform } from '@services/platform'; describe('CoreTextUtilsProvider', () => { @@ -24,7 +24,7 @@ describe('CoreTextUtilsProvider', () => { let textUtils: CoreTextUtilsProvider; beforeEach(() => { - mockSingleton(CoreApp, [], { isAndroid: () => config.platform === 'android' }); + mockSingleton(CorePlatform, [], { isAndroid: () => config.platform === 'android' }); mockSingleton(DomSanitizer, [], { bypassSecurityTrustUrl: url => url }); textUtils = new CoreTextUtilsProvider(); @@ -57,7 +57,7 @@ describe('CoreTextUtilsProvider', () => { expect(url).toEqual('geo:0,0?q=Moodle%20Spain%20HQ'); expect(DomSanitizer.bypassSecurityTrustUrl).toHaveBeenCalled(); - expect(CoreApp.isAndroid).toHaveBeenCalled(); + expect(CorePlatform.isAndroid).toHaveBeenCalled(); }); it('builds address URL for non-Android platforms', () => { @@ -73,7 +73,7 @@ describe('CoreTextUtilsProvider', () => { expect(url).toEqual('http://maps.google.com?q=Moodle%20Spain%20HQ'); expect(DomSanitizer.bypassSecurityTrustUrl).toHaveBeenCalled(); - expect(CoreApp.isAndroid).toHaveBeenCalled(); + expect(CorePlatform.isAndroid).toHaveBeenCalled(); }); it('doesn\'t build address if it\'s already a URL', () => { diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 33dad4c1112..f91e453e458 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -57,6 +57,7 @@ import { CoreNetwork } from '@services/network'; import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreUserSupport } from '@features/user/services/support'; import { CoreErrorInfoComponent } from '@components/error-info/error-info'; +import { CorePlatform } from '@services/platform'; /* * "Utils" service with helper functions for UI, DOM elements and HTML code. @@ -133,7 +134,7 @@ export class CoreDomUtilsProvider { const getAvailableBytes = async (): Promise => { const availableBytes = await CoreFile.calculateFreeSpace(); - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { return availableBytes; } else { // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. @@ -152,7 +153,7 @@ export class CoreDomUtilsProvider { } else { const availableSize = CoreTextUtils.bytesToSize(availableBytes, 2); - if (CoreApp.isAndroid() && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { + if (CorePlatform.isAndroid() && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { throw new CoreError( Translate.instant( 'core.course.insufficientavailablespace', @@ -338,7 +339,7 @@ export class CoreDomUtilsProvider { if (focusElement === document.activeElement) { await CoreUtils.nextTick(); - if (CoreApp.isAndroid() && this.supportsInputKeyboard(focusElement)) { + if (CorePlatform.isAndroid() && this.supportsInputKeyboard(focusElement)) { // On some Android versions the keyboard doesn't open automatically. CoreApp.openKeyboard(); } @@ -1529,7 +1530,7 @@ export class CoreDomUtilsProvider { buttons: buttons, }); - const isDevice = CoreApp.isAndroid() || CoreApp.isIOS(); + const isDevice = CorePlatform.isAndroid() || CorePlatform.isIOS(); if (!isDevice) { // Treat all anchors so they don't override the app. const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); @@ -1989,7 +1990,7 @@ export class CoreDomUtilsProvider { * @returns Promise resolved when done. */ async waitForResizeDone(windowWidth?: number, windowHeight?: number, retries = 0): Promise { - if (!CoreApp.isIOS()) { + if (!CorePlatform.isIOS()) { return; // Only wait in iOS. } diff --git a/src/core/services/utils/iframe.ts b/src/core/services/utils/iframe.ts index fc3a05e2561..1d79a73f0e0 100644 --- a/src/core/services/utils/iframe.ts +++ b/src/core/services/utils/iframe.ts @@ -16,7 +16,6 @@ import { Injectable } from '@angular/core'; import { WKUserScriptWindow } from 'cordova-plugin-wkuserscript'; import { WKWebViewCookiesWindow } from 'cordova-plugin-wkwebview-cookies'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFile } from '@services/file'; import { CoreFileHelper } from '@services/file-helper'; @@ -32,6 +31,7 @@ import { CoreWindow } from '@singletons/window'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CorePath } from '@singletons/path'; import { CorePromisedValue } from '@classes/promised-value'; +import { CorePlatform } from '@services/platform'; /** * Possible types of frame elements. @@ -531,7 +531,7 @@ export class CoreIframeUtilsProvider { } catch (error) { CoreDomUtils.showErrorModal(error); } - } else if (CoreApp.isIOS() && (!link.target || link.target == '_self') && element) { + } else if (CorePlatform.isIOS() && (!link.target || link.target == '_self') && element) { // In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them. event && event.preventDefault(); if (element.tagName.toLowerCase() == 'object') { @@ -564,7 +564,7 @@ export class CoreIframeUtilsProvider { * @returns Promise resolved when done. */ async fixIframeCookies(url: string): Promise { - if (!CoreApp.isIOS() || !url || CoreUrlUtils.isLocalFileUrl(url)) { + if (!CorePlatform.isIOS() || !url || CoreUrlUtils.isLocalFileUrl(url)) { // No need to fix cookies. return; } @@ -593,7 +593,7 @@ export class CoreIframeUtilsProvider { * @returns Boolean. */ shouldDisplayHelp(): boolean { - return CoreApp.isIOS() && CoreApp.getPlatformMajorVersion() >= 14; + return CorePlatform.isIOS() && CorePlatform.getPlatformMajorVersion() >= 14; } /** diff --git a/src/core/services/utils/text.ts b/src/core/services/utils/text.ts index 24065d5cc70..96fdce92a44 100644 --- a/src/core/services/utils/text.ts +++ b/src/core/services/utils/text.ts @@ -16,7 +16,6 @@ import { Injectable } from '@angular/core'; import { SafeUrl } from '@angular/platform-browser'; import { ModalOptions } from '@ionic/core'; -import { CoreApp } from '@services/app'; import { CoreAnyError, CoreError } from '@classes/errors/error'; import { DomSanitizer, makeSingleton, Translate } from '@singletons'; import { CoreWSFile } from '@services/ws'; @@ -28,6 +27,7 @@ import { CoreText } from '@singletons/text'; import { CoreUrl } from '@singletons/url'; import { AlertButton } from '@ionic/angular'; import { CorePath } from '@singletons/path'; +import { CorePlatform } from '@services/platform'; /** * Different type of errors the app can treat. @@ -187,7 +187,7 @@ export class CoreTextUtilsProvider { return DomSanitizer.bypassSecurityTrustUrl(address); } - return DomSanitizer.bypassSecurityTrustUrl((CoreApp.isAndroid() ? 'geo:0,0?q=' : 'http://maps.google.com?q=') + + return DomSanitizer.bypassSecurityTrustUrl((CorePlatform.isAndroid() ? 'geo:0,0?q=' : 'http://maps.google.com?q=') + encodeURIComponent(address)); } diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index a55bd56ffed..555b6eb1473 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -17,7 +17,6 @@ import { InAppBrowserObject, InAppBrowserOptions } from '@ionic-native/in-app-br import { FileEntry } from '@ionic-native/file/ngx'; import { Subscription } from 'rxjs'; -import { CoreApp } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreLang } from '@services/lang'; @@ -995,12 +994,12 @@ export class CoreUtilsProvider { const extension = CoreMimetypeUtils.getFileExtension(path); const mimetype = extension && CoreMimetypeUtils.getMimeType(extension); - if (mimetype == 'text/html' && CoreApp.isAndroid()) { + if (mimetype == 'text/html' && CorePlatform.isAndroid()) { // Open HTML local files in InAppBrowser, in system browser some embedded files aren't loaded. this.openInApp(path); return; - } else if (extension === 'apk' && CoreApp.isAndroid()) { + } else if (extension === 'apk' && CorePlatform.isAndroid()) { const url = await CoreUtils.ignoreErrors( CoreFilepool.getFileUrlByPath(CoreSites.getCurrentSiteId(), CoreFile.removeBasePath(path)), ); @@ -1065,7 +1064,7 @@ export class CoreUtilsProvider { options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default. options.allowInlineMediaPlayback = options.allowInlineMediaPlayback ?? 'yes'; // Allow playing inline videos in iOS. - if (!options.location && CoreApp.isIOS() && url.indexOf('file://') === 0) { + if (!options.location && CorePlatform.isIOS() && url.indexOf('file://') === 0) { // The URL uses file protocol, don't show it on iOS. // In Android we keep it because otherwise we lose the whole toolbar. options.location = 'no'; @@ -1190,7 +1189,7 @@ export class CoreUtilsProvider { * @returns Promise resolved when opened. */ async openOnlineFile(url: string): Promise { - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { // In Android we need the mimetype to open it. const mimetype = await this.ignoreErrors(this.getMimeTypeFromUrl(url)); @@ -1823,7 +1822,7 @@ export class CoreUtilsProvider { shouldOpenWithDialog(options: CoreUtilsOpenFileOptions = {}): boolean { const openFileAction = options.iOSOpenFileAction ?? CoreConstants.CONFIG.iOSDefaultOpenFileAction; - return CoreApp.isIOS() && openFileAction == OpenFileAction.OPEN_WITH; + return CorePlatform.isIOS() && openFileAction == OpenFileAction.OPEN_WITH; } } diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index bfcbae0b1ec..9f74b584bb5 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -13,7 +13,6 @@ // limitations under the License. import { CoreCancellablePromise } from '@classes/cancellable-promise'; -import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver } from '@singletons/events'; @@ -568,77 +567,6 @@ export class CoreDom { } } -<<<<<<< HEAD - /** - * Get all source URLs and types for a video or audio. - * - * @param mediaElement Audio or video element. - * @returns List of sources. - */ - static getMediaSources(mediaElement: HTMLVideoElement | HTMLAudioElement): CoreMediaSource[] { - const sources = Array.from(mediaElement.querySelectorAll('source')).map(source => ({ - src: source.src || source.getAttribute('target-src') || '', - type: source.type, - })); - - if (mediaElement.src) { - sources.push({ - src: mediaElement.src, - type: '', - }); - } - - return sources; - } - - /** - * Check if a source needs to be converted to be able to reproduce it. - * - * @param source Source. - * @returns Whether needs conversion. - */ - static sourceNeedsConversion(source: CoreMediaSource): boolean { - if (!CoreApp.isIOS()) { - return false; - } - - let extension = source.type ? CoreMimetypeUtils.getExtension(source.type) : undefined; - if (!extension) { - extension = CoreMimetypeUtils.guessExtensionFromUrl(source.src); - } - - return !!extension && ['ogv', 'webm', 'oga', 'ogg'].includes(extension); - } - - /** - * Check if JS player should be used for a certain source. - * - * @param source Source. - * @returns Whether JS player should be used. - */ - static sourceUsesJavascriptPlayer(source: CoreMediaSource): boolean { - // For now, only use JS player if the source needs to be converted. - return CoreDom.sourceNeedsConversion(source); - } - - /** - * Check if JS player should be used for a certain audio or video. - * - * @param mediaElement Media element. - * @returns Whether JS player should be used. - */ - static mediaUsesJavascriptPlayer(mediaElement: HTMLVideoElement | HTMLAudioElement): boolean { - if (!CoreApp.isIOS()) { - return false; - } - - const sources = CoreDom.getMediaSources(mediaElement); - - return sources.some(source => CoreDom.sourceUsesJavascriptPlayer(source)); - } - -======= ->>>>>>> f42ea632ca (a) } /** diff --git a/src/testing/utils.ts b/src/testing/utils.ts index e790fff28c7..2a54ef6748c 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -62,6 +62,8 @@ const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, unknown][] = [ [CorePlatform, mock({ is: () => false, isMobile: () => false, + isAndroid: () => false, + isIOS: () => false, ready: () => Promise.resolve(), resume: new Subject(), })], From 5adad7fd00ce4b8c623079f1a5617bec2834e445 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Fri, 10 Feb 2023 14:57:56 +0100 Subject: [PATCH 35/80] MOBILE-4132 Book: Fix single activity navigation --- src/addons/mod/book/components/index/index.ts | 14 ++++---- .../book/tests/behat/single_activity.feature | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 src/addons/mod/book/tests/behat/single_activity.feature diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index 191a698faa1..93c64972f41 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -18,6 +18,7 @@ import { AddonModBook, AddonModBookBookWSData, AddonModBookNumbering, AddonModBo import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; +import { AddonModBookModuleHandlerService } from '../../services/handlers/module'; /** * Component that displays a book entry page. @@ -109,14 +110,11 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp * * @param chapterId Chapter to open, undefined for last chapter viewed. */ - openBook(chapterId?: number): void { - CoreNavigator.navigate('contents', { - params: { - cmId: this.module.id, - courseId: this.courseId, - chapterId, - }, - }); + async openBook(chapterId?: number): Promise { + await CoreNavigator.navigateToSitePath( + `${AddonModBookModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/contents`, + { params: { chapterId } }, + ); this.hasStartedBook = true; } diff --git a/src/addons/mod/book/tests/behat/single_activity.feature b/src/addons/mod/book/tests/behat/single_activity.feature new file mode 100644 index 00000000000..f247c203477 --- /dev/null +++ b/src/addons/mod/book/tests/behat/single_activity.feature @@ -0,0 +1,33 @@ +@app @javascript @mod @mod_book +Feature: Test single activity of book type in app + In order to view a book while using the mobile app + As a student + I need single activity of book type functionality to work + + Background: + Given the following "users" exist: + | username | firstname | lastname | + | student1 | First | Student | + And the following "courses" exist: + | fullname | shortname | category | format | activitytype | + | Course 1 | C1 | 0 | singleactivity | book | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + And the following "activity" exist: + | activity | name | intro | course | idnumber | numbering | section | + | book | Single activity book | Test book description | C1 | 1 | 1 | 0 | + And the following "mod_book > chapter" exist: + | book | title | content | subchapter | hidden | pagenum | + | Single activity book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | Single activity book | Chapt 2 | This is the second chapter | 0 | 0 | 1 | + | Single activity book | Chapt 3 | This is the third chapter | 0 | 0 | 1 | + + Scenario: Single activity book + Given I entered the course "Course 1" as "student1" in the app + Then I should find "Chapt 1" in the app + And I should find "Chapt 2" in the app + And I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + But I should not find "This is the second chapter" in the app From 8996cef6487c7003b210994b76202f6ae6798e69 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 13 Feb 2023 10:00:53 +0100 Subject: [PATCH 36/80] MOBILE-4069 a11y: Remove unneeded aria-labelledby With the latest Ionic update, these aria-labelledby aren't needed and one of them even caused a behat test to fail --- .../forum/pages/new-discussion/new-discussion.html | 8 ++++---- src/addons/mod/glossary/pages/edit/edit.html | 11 +++++------ src/addons/mod/lesson/pages/player/player.html | 8 +++----- .../mod/lesson/pages/user-retake/user-retake.html | 5 ++--- .../components/index/addon-mod-survey-index.html | 5 ++--- .../components/group-selector/group-selector.html | 5 ++--- .../components/group-selector/group-selector.ts | 14 ++------------ .../login/pages/email-signup/email-signup.html | 5 ++--- 8 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.html b/src/addons/mod/forum/pages/new-discussion/new-discussion.html index f403daa714c..205501ce769 100644 --- a/src/addons/mod/forum/pages/new-discussion/new-discussion.html +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.html @@ -46,10 +46,10 @@

{{ 'addon.mod_forum.advanced' | translate }}

- {{ 'addon.mod_forum.group' | translate }} - + {{ 'addon.mod_forum.group' | translate }} + {{ group.name }} diff --git a/src/addons/mod/glossary/pages/edit/edit.html b/src/addons/mod/glossary/pages/edit/edit.html index f943c19e3f5..8850c93d39d 100644 --- a/src/addons/mod/glossary/pages/edit/edit.html +++ b/src/addons/mod/glossary/pages/edit/edit.html @@ -28,11 +28,11 @@

- + {{ 'addon.mod_glossary.categories' | translate }} - {{ category.name }} @@ -40,11 +40,10 @@

- + {{ 'addon.mod_glossary.aliases' | translate }} - + diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index da29653c950..9613763db44 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -138,14 +138,12 @@

{{ 'addon.mod_lesson.youranswer' | translate }}

- +

- + {{option.label}} diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.html b/src/addons/mod/lesson/pages/user-retake/user-retake.html index 41e59c571ff..46392fceca8 100644 --- a/src/addons/mod/lesson/pages/user-retake/user-retake.html +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.html @@ -27,9 +27,8 @@

{{student.fullname}}

- {{ 'addon.mod_lesson.attemptheader' | translate }} - {{ 'addon.mod_lesson.attemptheader' | translate }} + {{retake.label}} diff --git a/src/addons/mod/survey/components/index/addon-mod-survey-index.html b/src/addons/mod/survey/components/index/addon-mod-survey-index.html index a8aa106d489..94418bb86f4 100644 --- a/src/addons/mod/survey/components/index/addon-mod-survey-index.html +++ b/src/addons/mod/survey/components/index/addon-mod-survey-index.html @@ -102,13 +102,12 @@

- + {{question.num}}. {{ question.text }} - + diff --git a/src/core/components/group-selector/group-selector.html b/src/core/components/group-selector/group-selector.html index 3c667d8ea30..8febfda048d 100644 --- a/src/core/components/group-selector/group-selector.html +++ b/src/core/components/group-selector/group-selector.html @@ -7,12 +7,11 @@ - + {{'core.groupsseparate' | translate }} {{'core.groupsvisible' | translate }} - {{groupOpt.name}} diff --git a/src/core/components/group-selector/group-selector.ts b/src/core/components/group-selector/group-selector.ts index b98b5ec5e72..2365e3ffe18 100644 --- a/src/core/components/group-selector/group-selector.ts +++ b/src/core/components/group-selector/group-selector.ts @@ -12,9 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; import { CoreGroupInfo } from '@services/groups'; -import { CoreUtils } from '@services/utils/utils'; /** * Component to display a group selector. @@ -24,20 +23,11 @@ import { CoreUtils } from '@services/utils/utils'; templateUrl: 'group-selector.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CoreGroupSelectorComponent implements OnInit { +export class CoreGroupSelectorComponent { @Input() groupInfo?: CoreGroupInfo; @Input() multipleGroupsMessage?: string; @Input() selected!: number; @Output() selectedChange = new EventEmitter(); - id!: number; - - /** - * @inheritdoc - */ - ngOnInit(): void { - this.id = CoreUtils.getUniqueId('CoreGroupSelectorComponent'); - } - } diff --git a/src/core/features/login/pages/email-signup/email-signup.html b/src/core/features/login/pages/email-signup/email-signup.html index fa5eed5018e..0fa136230d3 100644 --- a/src/core/features/login/pages/email-signup/email-signup.html +++ b/src/core/features/login/pages/email-signup/email-signup.html @@ -159,9 +159,8 @@

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

- {{ 'core.user.country' | translate }} - + {{ 'core.user.country' | translate }} + {{ 'core.login.selectacountry' | translate }} {{country.name}} From c08d638026d15b4489509f53e1e5ade9c54623f4 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 14 Feb 2023 09:21:39 +0100 Subject: [PATCH 37/80] MOBILE-4065 a11y: Hide pages under user tours Following the same strategy as Ionic's built-in overlays https://github.com/ionic-team/ionic-framework/blob/1b30fc97d33e761866b4bcf7518efcdeb753032d/core/src/utils/overlays.ts#L388..L401 --- .../side-blocks-tour/side-blocks-tour.html | 2 +- .../course-index-tour/course-index-tour.html | 2 +- .../user-menu-tour/user-menu-tour.html | 2 +- .../features/usertours/services/user-tours.ts | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html index 00793c3ff85..49c98e1168e 100644 --- a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html +++ b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html @@ -1,4 +1,4 @@ -

{{ 'core.block.tour_navigation_dashboard_title' | translate }}

+

{{ 'core.block.tour_navigation_dashboard_title' | translate }}

{{ 'core.block.tour_navigation_dashboard_content' | translate }}

{{ 'core.endonesteptour' | translate }} diff --git a/src/core/features/course/components/course-index-tour/course-index-tour.html b/src/core/features/course/components/course-index-tour/course-index-tour.html index dbf65101be3..3b70e424497 100644 --- a/src/core/features/course/components/course-index-tour/course-index-tour.html +++ b/src/core/features/course/components/course-index-tour/course-index-tour.html @@ -1,4 +1,4 @@ -

{{ 'core.course.tour_navigation_course_index_student_title' | translate }}

+

{{ 'core.course.tour_navigation_course_index_student_title' | translate }}

{{ 'core.course.tour_navigation_course_index_student_content' | translate }}

{{ 'core.endonesteptour' | translate }} diff --git a/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html index efef95b508b..d3556b82892 100644 --- a/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html +++ b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html @@ -1,4 +1,4 @@ -

{{ 'core.mainmenu.usermenutourtitle' | translate }}

+

{{ 'core.mainmenu.usermenutourtitle' | translate }}

{{ 'core.mainmenu.usermenutourdescription' | translate }}

{{ 'core.endonesteptour' | translate }} diff --git a/src/core/features/usertours/services/user-tours.ts b/src/core/features/usertours/services/user-tours.ts index a79b60f934c..a098732cb94 100644 --- a/src/core/features/usertours/services/user-tours.ts +++ b/src/core/features/usertours/services/user-tours.ts @@ -115,6 +115,7 @@ export class CoreUserToursService { await CoreUtils.wait(delay ?? 200); const container = document.querySelector('ion-app') ?? document.body; + const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root'); const element = await AngularFrameworkDelegate.attachViewToDom( container, CoreUserToursUserTourComponent, @@ -122,6 +123,8 @@ export class CoreUserToursService { ); const tour = CoreDirectivesRegistry.require(element, CoreUserToursUserTourComponent); + viewContainer?.setAttribute('aria-hidden', 'true'); + return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus); } @@ -132,6 +135,15 @@ export class CoreUserToursService { */ async dismiss(acknowledge: boolean = true): Promise { await this.getForegroundTour()?.dismiss(acknowledge); + + if (this.hasVisibleTour()) { + return; + } + + const container = document.querySelector('ion-app') ?? document.body; + const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root'); + + viewContainer?.removeAttribute('aria-hidden'); } /** @@ -241,6 +253,15 @@ export class CoreUserToursService { return this.tours.find(({ visible }) => visible)?.component; } + /** + * Check whether any tour is visible. + * + * @returns Whether any tour is visible. + */ + protected hasVisibleTour(): boolean { + return this.tours.some(({ visible }) => visible); + } + /** * Returns the tour index in the stack. * From 3dd333243b76658ed85750d14c31263ab9d93092 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 13 Feb 2023 11:56:53 +0100 Subject: [PATCH 38/80] MOBILE-4069 behat: Increase Docker PHP version --- .github/workflows/acceptance.yml | 2 +- .github/workflows/performance.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index d8ff19c9d0c..b2df84a3dd6 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -26,7 +26,7 @@ jobs: env: MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_BROWSER: chrome - MOODLE_DOCKER_PHP_VERSION: 7.4 + MOODLE_DOCKER_PHP_VERSION: '8.0' MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }} MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }} BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }} diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 746a6c38a96..47e3fbf7130 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -8,7 +8,7 @@ jobs: env: MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_BROWSER: chrome - MOODLE_DOCKER_PHP_VERSION: 7.4 + MOODLE_DOCKER_PHP_VERSION: '8.0' steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v3 From 230b4b6c5fa38fdb9453f9d51ee4a629153b43e5 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 30 Jan 2023 13:40:56 +0100 Subject: [PATCH 39/80] MOBILE-4254 behat: Configure snapshot tests --- .github/workflows/acceptance.yml | 20 +++++++++-- gulpfile.js | 2 +- .../tests/behat/snapshots/failures/.gitkeep | 0 scripts/build-behat-plugin.js | 31 +++++++++++------- .../login/tests/behat/basic_usage.feature | 3 ++ ...displayed-when-adding-a-new-account_13.png | Bin 0 -> 31739 bytes ...-displayed-when-adding-a-new-account_9.png | Bin 0 -> 41070 bytes 7 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 local_moodleappbehat/tests/behat/snapshots/failures/.gitkeep create mode 100644 src/core/features/login/tests/behat/snapshots/test-basic-usage-of-login-in-app-add-a-new-account-in-the-app--site-name-in-displayed-when-adding-a-new-account_13.png create mode 100644 src/core/features/login/tests/behat/snapshots/test-basic-usage-of-login-in-app-add-a-new-account-in-the-app--site-name-in-displayed-when-adding-a-new-account_9.png diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index b2df84a3dd6..8de5537fda6 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -42,6 +42,8 @@ jobs: git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker - name: Install npm packages run: npm ci --no-audit + - name: Install Behat Snapshots plugin + run: git clone --branch main --depth 1 https://github.com/NoelDeMartin/moodle-local_behatsnapshots $GITHUB_WORKSPACE/moodle/local/behatsnapshots - name: Generate Behat tests plugin run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle @@ -52,10 +54,18 @@ jobs: cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_increasetimeout = 2;" $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php + sed -i "61i\$CFG->behat_snapshots_path = '/var/www/html/local/moodleappbehat/tests/behat/snapshots';" $GITHUB_WORKSPACE/moodle/config.php echo "define('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER', 'http://bbbmockserver/hash' . sha1(\$CFG->behat_wwwroot));" >> $GITHUB_WORKSPACE/moodle/config.php $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db + - name: Install Imagick PHP extension + run: | + export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle + ./moodle-docker/bin/moodle-docker-compose exec webserver apt-get update + ./moodle-docker/bin/moodle-docker-compose exec webserver apt-get install -y libmagickwand-dev --no-install-recommends + ./moodle-docker/bin/moodle-docker-compose exec webserver pecl install imagick + ./moodle-docker/bin/moodle-docker-compose exec webserver docker-php-ext-enable imagick - name: Compile & launch app with Docker run: | docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat . @@ -65,8 +75,14 @@ jobs: - name: Init Behat run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle - $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php --parallel=8 --optimize-runs='@app&&$BEHAT_TAGS'" + $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php --parallel=8 --optimize-runs='@app&&~@local&&$BEHAT_TAGS'" - name: Run Behat tests run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle - $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&$BEHAT_TAGS' --auto-rerun=3" + $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&~@local&&$BEHAT_TAGS' --auto-rerun=3" + - name: Upload Snapshot failures + uses: actions/upload-artifact@v3 + if: ${{ failure() }} + with: + name: snapshot_failures + path: moodle/local/moodleappbehat/tests/behat/snapshots/failures/* diff --git a/gulpfile.js b/gulpfile.js index 60451192ff1..d7098d9b715 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -71,5 +71,5 @@ gulp.task('watch', () => { }); gulp.task('watch-behat', () => { - gulp.watch(['./src/**/*.feature', './local_moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); + gulp.watch(['./src/**/*.feature', './src/**/*.png', './local_moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); }); diff --git a/local_moodleappbehat/tests/behat/snapshots/failures/.gitkeep b/local_moodleappbehat/tests/behat/snapshots/failures/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/build-behat-plugin.js b/scripts/build-behat-plugin.js index 9dc94bcc871..621ad007fca 100755 --- a/scripts/build-behat-plugin.js +++ b/scripts/build-behat-plugin.js @@ -76,37 +76,46 @@ async function main() { }; writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); - // Copy feature files. + // Copy feature and snapshot files. if (!excludeFeatures) { const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; - copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory }); + copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory }); const behatFeaturesPath = `${pluginPath}/tests/behat`; if (!existsSync(behatFeaturesPath)) { mkdirSync(behatFeaturesPath, {recursive: true}); } - for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) { - const featurePath = dirname(featureFile); - if (!featurePath.endsWith('/tests/behat')) { + for await (const file of getDirectoryFiles(behatTempFeaturesPath)) { + const filePath = dirname(file); + + if (filePath.endsWith('/tests/behat/snapshots')) { + renameSync(file, behatFeaturesPath + '/snapshots/' + basename(file)); + + continue; + } + + if (!filePath.endsWith('/tests/behat')) { continue; } - const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length)); + const newPath = filePath.substring(0, filePath.length - ('/tests/behat'.length)); const searchRegExp = /\//g; const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; - const featureFilename = prefix + '-' + basename(featureFile); - renameSync(featureFile, behatFeaturesPath + '/' + featureFilename); + const featureFilename = prefix + '-' + basename(file); + renameSync(file, behatFeaturesPath + '/' + featureFilename); } rmSync(behatTempFeaturesPath, {recursive: true}); } } -function isFeatureFileOrDirectory(src) { - const stats = statSync(src); +function shouldCopyFileOrDirectory(path) { + const stats = statSync(path); - return stats.isDirectory() || extname(src) === '.feature'; + return stats.isDirectory() + || extname(path) === '.feature' + || extname(path) === '.png'; } function isExcluded(file, exclusions) { diff --git a/src/core/features/login/tests/behat/basic_usage.feature b/src/core/features/login/tests/behat/basic_usage.feature index 0aef46f2815..f7d7a55d1a5 100755 --- a/src/core/features/login/tests/behat/basic_usage.feature +++ b/src/core/features/login/tests/behat/basic_usage.feature @@ -30,12 +30,15 @@ Feature: Test basic usage of login in app And I set the field "Your site" to "$WWWROOT" in the app And I press "Connect to your site" in the app Then I should find "Acceptance test site" in the app + And I replace "/.*/" within ".core-siteurl" with "https://campus.example.edu" + And the UI should match the snapshot When I set the following fields to these values in the app: | Username | student1 | | Password | student1 | And I press "Log in" near "Forgotten your username or password?" in the app Then I should find "Acceptance test site" in the app + And the UI should match the snapshot But I should not find "Log in" in the app Scenario: Add a non existing account diff --git a/src/core/features/login/tests/behat/snapshots/test-basic-usage-of-login-in-app-add-a-new-account-in-the-app--site-name-in-displayed-when-adding-a-new-account_13.png b/src/core/features/login/tests/behat/snapshots/test-basic-usage-of-login-in-app-add-a-new-account-in-the-app--site-name-in-displayed-when-adding-a-new-account_13.png new file mode 100644 index 0000000000000000000000000000000000000000..ed1fdbe497ef6863860f611e78a1fc81f96ff353 GIT binary patch literal 31739 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 Date: Tue, 14 Feb 2023 12:25:47 +0100 Subject: [PATCH 40/80] MOBILE-4254 ci: Upload behat faildumps artifacts --- .github/workflows/acceptance.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 8de5537fda6..c23c2c1838f 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -42,6 +42,10 @@ jobs: git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker - name: Install npm packages run: npm ci --no-audit + - name: Create Behat faildumps folder + run: | + mkdir moodle/behatfaildumps + chmod 777 moodle/behatfaildumps - name: Install Behat Snapshots plugin run: git clone --branch main --depth 1 https://github.com/NoelDeMartin/moodle-local_behatsnapshots $GITHUB_WORKSPACE/moodle/local/behatsnapshots - name: Generate Behat tests plugin @@ -52,6 +56,7 @@ jobs: run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php + sed -i "61c\$CFG->behat_faildump_path = '/var/www/html/behatfaildumps';" $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_increasetimeout = 2;" $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_snapshots_path = '/var/www/html/local/moodleappbehat/tests/behat/snapshots';" $GITHUB_WORKSPACE/moodle/config.php @@ -86,3 +91,9 @@ jobs: with: name: snapshot_failures path: moodle/local/moodleappbehat/tests/behat/snapshots/failures/* + - name: Upload Behat failures + uses: actions/upload-artifact@v3 + if: ${{ failure() }} + with: + name: behat_failures + path: moodle/behatfaildumps From d55157bd5b7891a7919e87c2f8713726d9e83ada Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 15 Feb 2023 13:34:17 +0100 Subject: [PATCH 41/80] MOBILE-4254 behat: Test course contents UI --- .../course/tests/behat/basic_usage.feature | 4 ++++ ...ne-course-in-app-view-course-contents_39.png | Bin 0 -> 36971 bytes 2 files changed, 4 insertions(+) create mode 100644 src/core/features/course/tests/behat/snapshots/test-basic-usage-of-one-course-in-app-view-course-contents_39.png diff --git a/src/core/features/course/tests/behat/basic_usage.feature b/src/core/features/course/tests/behat/basic_usage.feature index a569847a975..74b73562905 100755 --- a/src/core/features/course/tests/behat/basic_usage.feature +++ b/src/core/features/course/tests/behat/basic_usage.feature @@ -89,6 +89,10 @@ Feature: Test basic usage of one course in app And I should find "Test scorm name" in the app And I should find "Test workshop name" in the app + When I set "page-core-course-index .core-course-thumb" styles to "--course-color" "lightblue" + And I set "page-core-course-index .core-course-thumb" styles to "--course-color-tint" "white" + Then the UI should match the snapshot + When I press "Choice course 1" in the app Then the header should be "Choice course 1" in the app diff --git a/src/core/features/course/tests/behat/snapshots/test-basic-usage-of-one-course-in-app-view-course-contents_39.png b/src/core/features/course/tests/behat/snapshots/test-basic-usage-of-one-course-in-app-view-course-contents_39.png new file mode 100644 index 0000000000000000000000000000000000000000..e4060a65d7e985c54e1f0b597d163fed4044608c GIT binary patch literal 36971 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

R8mg5asR(WmLFe#gx1Q^XO6!?Mci!2wUO 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 Date: Mon, 13 Feb 2023 12:28:10 +0100 Subject: [PATCH 42/80] MOBILE-4065 dev: Reset site onboarding when reseting tours --- src/core/features/settings/pages/dev/dev.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/features/settings/pages/dev/dev.ts b/src/core/features/settings/pages/dev/dev.ts index 63be91d598f..c7de23c2477 100644 --- a/src/core/features/settings/pages/dev/dev.ts +++ b/src/core/features/settings/pages/dev/dev.ts @@ -13,8 +13,10 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CoreUserTours } from '@features/usertours/services/user-tours'; +import { CoreConfig } from '@services/config'; import { CorePlatform } from '@services/platform'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -149,6 +151,9 @@ export class CoreSettingsDevPage implements OnInit { */ async resetUserTours(): Promise { await CoreUserTours.resetTours(); + + await CoreConfig.delete(CoreLoginHelperProvider.ONBOARDING_DONE); + CoreDomUtils.showToast('User tours have been reseted'); } From 50773aa46da4466555c14ac362193aaecaa7c78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 13 Feb 2023 12:59:18 +0100 Subject: [PATCH 43/80] MOBILE-4065 a11y: Add aria-role region to headings order --- src/core/features/block/components/block/block.scss | 2 +- src/core/features/block/components/block/block.ts | 1 + src/core/features/block/components/block/core-block.html | 3 ++- src/core/features/courses/pages/my/my.html | 5 +++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/features/block/components/block/block.scss b/src/core/features/block/components/block/block.scss index 72e49c32fc8..721e6f354e3 100644 --- a/src/core/features/block/components/block/block.scss +++ b/src/core/features/block/components/block/block.scss @@ -4,7 +4,7 @@ flex-direction: column; background: var(--background); - ion-item-divider { + ::ng-deep ion-item-divider { min-height: var(--item-divider-min-height); } diff --git a/src/core/features/block/components/block/block.ts b/src/core/features/block/components/block/block.ts index ae37afab250..b505f69373a 100644 --- a/src/core/features/block/components/block/block.ts +++ b/src/core/features/block/components/block/block.ts @@ -35,6 +35,7 @@ export class CoreBlockComponent implements OnChanges, OnDestroy { @Input() contextLevel!: string; // The context where the block will be used. @Input() instanceId!: number; // The instance ID associated with the context level. @Input() extraData!: Record; // Any extra data to be passed to the block. + @Input() labelledBy?: string; componentClass?: Type; // The class of the component to render. data: Record = {}; // Data to pass to the component. diff --git a/src/core/features/block/components/block/core-block.html b/src/core/features/block/components/block/core-block.html index 663799e01f4..100fe873ad3 100644 --- a/src/core/features/block/components/block/core-block.html +++ b/src/core/features/block/components/block/core-block.html @@ -1,4 +1,5 @@ - + diff --git a/src/core/features/courses/pages/my/my.html b/src/core/features/courses/pages/my/my.html index 1f307e4b7f0..83cc1fd54d4 100644 --- a/src/core/features/courses/pages/my/my.html +++ b/src/core/features/courses/pages/my/my.html @@ -22,7 +22,7 @@

-

{{ 'core.courses.mycourses' | translate }}

+

{{ 'core.courses.mycourses' | translate }}

@@ -46,7 +46,8 @@

{{ 'core.courses.mycourses' | translate }}

- + Date: Mon, 13 Feb 2023 14:29:23 +0100 Subject: [PATCH 44/80] MOBILE-4065 theme: Add separate variables for calendar events --- src/theme/globals.variables.scss | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/theme/globals.variables.scss b/src/theme/globals.variables.scss index 49893eedf71..dda1c0c6775 100644 --- a/src/theme/globals.variables.scss +++ b/src/theme/globals.variables.scss @@ -124,11 +124,17 @@ $activity-icon-colors: ( interface: #a378ff ) !default; +$calendar-event-category-category: #8e24aa !default; +$calendar-event-category-course: $red !default; +$calendar-event-category-group: $yellow !default; +$calendar-event-category-user: $blue !default; +$calendar-event-category-site: $green !default; + // Calendar event category background colors. $calendar-event-category-colors: ( - category: #8e24aa, - course: $red, - group: $yellow, - user: $blue, - site: $green + category: $calendar-event-category-category, + course: $calendar-event-category-course, + group: $calendar-event-category-group, + user: $calendar-event-category-user, + site: $calendar-event-category-site, ) !default; From a6de01f5ae16be243607f38148e7981598083c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 13 Feb 2023 12:09:10 +0100 Subject: [PATCH 45/80] MOBILE-4065 a11y: Fix lots of headings --- .../pages/issued-badge/issued-badge.html | 48 +++++++++--------- src/addons/blog/pages/entries/entries.html | 21 ++++---- .../components/calendar/calendar.scss | 2 +- src/addons/calendar/pages/day/day.html | 2 +- src/addons/calendar/pages/day/day.scss | 2 +- src/addons/calendar/pages/event/event.html | 14 ++--- .../pages/competency/competency.html | 4 +- .../coursecompetencies.html | 2 +- src/addons/competency/pages/plan/plan.html | 2 +- .../coursecompletion/pages/report/report.html | 6 +-- .../conversation-info/conversation-info.html | 10 ++-- .../pages/discussions-35/discussions.html | 2 +- .../edit-feedback-modal.html | 2 +- .../index/addon-mod-assign-index.html | 16 +++--- .../addon-mod-assign-submission.html | 48 +++++++++--------- ...ddon-mod-assign-submission-onlinetext.html | 2 +- .../components/index/index.html | 24 ++++----- src/addons/mod/book/components/toc/toc.html | 2 +- .../components/users-modal/users-modal.html | 2 +- .../mod/data/components/search/search.html | 2 +- .../index/addon-mod-feedback-index.html | 14 ++--- .../mod/feedback/pages/attempt/attempt.html | 4 +- src/addons/mod/feedback/pages/form/form.html | 2 +- .../mod/forum/components/post/post.html | 2 +- .../sort-order-selector.html | 4 +- .../index/addon-mod-h5pactivity-index.html | 2 +- .../attempt-results/attempt-results.html | 14 ++--- .../pages/user-attempts/user-attempts.html | 4 +- src/addons/mod/imscp/components/toc/toc.html | 2 +- .../index/addon-mod-lesson-index.html | 24 ++++----- .../components/menu-modal/menu-modal.html | 2 +- .../password-modal/password-modal.html | 2 +- .../mod/lesson/pages/player/player.html | 2 +- .../lesson/pages/user-retake/user-retake.html | 14 ++--- ...ddon-mod-quiz-access-offline-attempts.html | 2 +- .../addon-mod-quiz-access-password.html | 2 +- .../addon-mod-quiz-access-time-limit.html | 2 +- .../navigation-modal/navigation-modal.html | 2 +- .../preflight-modal/preflight-modal.html | 2 +- .../mod/quiz/pages/attempt/attempt.html | 10 ++-- src/addons/mod/quiz/pages/player/player.html | 2 +- src/addons/mod/quiz/pages/review/review.html | 14 ++--- .../index/addon-mod-resource-index.html | 10 ++-- .../index/addon-mod-scorm-index.html | 2 +- src/addons/mod/scorm/components/toc/toc.html | 2 +- .../components/index/addon-mod-url-index.html | 2 +- src/addons/mod/wiki/components/map/map.html | 2 +- .../addon-mod-wiki-subwiki-picker.html | 2 +- ...shop-assessment-strategy-accumulative.html | 11 ++-- ...workshop-assessment-strategy-comments.html | 7 +-- ...orkshop-assessment-strategy-numerrors.html | 7 +-- ...d-workshop-assessment-strategy-rubric.html | 4 +- ...ddon-mod-workshop-assessment-strategy.html | 4 +- .../index/addon-mod-workshop-index.html | 34 +++++++------ .../mod/workshop/components/phase/phase.html | 4 +- .../addon-mod-workshop-submission.html | 4 +- .../workshop/pages/assessment/assessment.html | 8 +-- .../workshop/pages/submission/submission.html | 12 ++--- .../notes/components/add/add-modal.html | 2 +- src/addons/notes/pages/list/list.html | 2 +- .../pages/course-storage/course-storage.html | 4 +- .../addon-user-profile-field-checkbox.html | 4 +- .../addon-user-profile-field-datetime.html | 2 +- .../addon-user-profile-field-menu.html | 2 +- .../addon-user-profile-field-text.html | 2 +- .../addon-user-profile-field-textarea.html | 2 +- .../tabs-outlet/core-tabs-outlet.html | 2 +- src/core/components/tabs/core-tabs.html | 2 +- src/core/components/tabs/tabs.scss | 6 ++- .../choose-site-modal/choose-site-modal.html | 4 +- .../course-format/course-format.html | 5 +- .../components/course-index/course-index.html | 2 +- .../self-enrol-password.html | 2 +- .../courses/pages/categories/categories.html | 4 +- .../login/components/site-help/site-help.html | 12 ++--- .../login/components/site-help/site-help.scss | 6 ++- .../site-onboarding/site-onboarding.html | 4 +- .../site-onboarding/site-onboarding.scss | 4 ++ .../login/pages/credentials/credentials.html | 8 +-- .../pages/email-signup/email-signup.html | 18 +++---- .../forgotten-password.html | 2 +- .../login/pages/reconnect/reconnect.html | 2 +- ...-displayed-when-adding-a-new-account_9.png | Bin 41070 -> 41071 bytes .../components/user-menu/user-menu.html | 6 +-- .../components/ratings/ratings-modal.html | 4 +- .../set-reminder-custom.html | 2 +- .../set-reminder-menu/set-reminder-menu.html | 2 +- .../report-column/report-column.html | 7 ++- .../reportbuilder/pages/list/list.html | 2 +- .../features/settings/pages/about/about.html | 2 +- src/core/features/settings/pages/dev/dev.html | 18 +++---- .../settings/pages/deviceinfo/deviceinfo.html | 45 ++++++++-------- .../settings/pages/general/general.html | 14 ++--- .../pages/space-usage/space-usage.html | 2 +- .../components/list-modal/list-modal.html | 2 +- .../features/sitehome/pages/index/index.html | 8 +-- .../tag/components/feed/core-tag-feed.html | 2 +- src/core/features/tag/pages/index/index.html | 10 ++-- .../tag-area/core-user-tag-area.html | 2 +- src/core/features/user/pages/about/about.html | 22 ++++---- .../components/qr-scanner/qr-scanner.html | 2 +- .../features/viewer/components/text/text.html | 2 +- 102 files changed, 368 insertions(+), 345 deletions(-) diff --git a/src/addons/badges/pages/issued-badge/issued-badge.html b/src/addons/badges/pages/issued-badge/issued-badge.html index 5f233087f2a..abb339c92b7 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.html +++ b/src/addons/badges/pages/issued-badge/issued-badge.html @@ -33,7 +33,7 @@

{{ 'addon.badges.recipientdetails' | translate}}

-

{{ 'core.name' | translate}}

+

{{ 'core.name' | translate}}

{{ user.fullname }}

@@ -48,13 +48,13 @@

{{ 'addon.badges.issuerdetails' | translate}}

-

{{ 'addon.badges.issuername' | translate}}

+

{{ 'addon.badges.issuername' | translate}}

{{ badge.issuername }}

-

{{ 'addon.badges.contact' | translate}}

+

{{ 'addon.badges.contact' | translate}}

{{ badge.issuercontact }}

@@ -70,37 +70,37 @@

{{ 'addon.badges.badgedetails' | translate}}

-

{{ 'core.name' | translate}}

+

{{ 'core.name' | translate}}

{{ badge.name }}

-

{{ 'addon.badges.version' | translate}}

+

{{ 'addon.badges.version' | translate}}

{{ badge.version }}

-

{{ 'addon.badges.language' | translate}}

+

{{ 'addon.badges.language' | translate}}

{{ badge.language }}

-

{{ 'core.description' | translate}}

+

{{ 'core.description' | translate}}

{{ badge.description }}

-

{{ 'addon.badges.imageauthorname' | translate}}

+

{{ 'addon.badges.imageauthorname' | translate}}

{{ badge.imageauthorname }}

-

{{ 'addon.badges.imageauthoremail' | translate}}

+

{{ 'addon.badges.imageauthoremail' | translate}}

{{ badge.imageauthoremail }}

@@ -108,19 +108,19 @@

{{ 'addon.badges.imageauthoremail' | translate}}

-

{{ 'addon.badges.imageauthorurl' | translate}}

+

{{ 'addon.badges.imageauthorurl' | translate}}

{{ badge.imageauthorurl }}

-

{{ 'addon.badges.imagecaption' | translate}}

+

{{ 'addon.badges.imagecaption' | translate}}

{{ badge.imagecaption }}

-

{{ 'core.course' | translate}}

+

{{ 'core.course' | translate}}

@@ -138,13 +138,13 @@

{{ 'addon.badges.issuancedetails' | translate}}

-

{{ 'addon.badges.dateawarded' | translate}}

+

{{ 'addon.badges.dateawarded' | translate}}

{{badge.dateissued * 1000 | coreFormatDate }}

-

{{ 'addon.badges.expirydate' | translate}}

+

{{ 'addon.badges.expirydate' | translate}}

{{ badge.dateexpire * 1000 | coreFormatDate }} @@ -165,13 +165,13 @@

{{ 'addon.badges.bendorsement' | translate}}

-

{{ 'addon.badges.issuername' | translate}}

+

{{ 'addon.badges.issuername' | translate}}

{{ badge.endorsement.issuername }}

-

{{ 'addon.badges.issueremail' | translate}}

+

{{ 'addon.badges.issueremail' | translate}}

{{ badge.endorsement.issueremail }} @@ -181,25 +181,25 @@

{{ 'addon.badges.issueremail' | translate}}

-

{{ 'addon.badges.issuerurl' | translate}}

+

{{ 'addon.badges.issuerurl' | translate}}

{{ badge.endorsement.issuerurl }}

-

{{ 'addon.badges.dateawarded' | translate}}

+

{{ 'addon.badges.dateawarded' | translate}}

{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}

-

{{ 'addon.badges.claimid' | translate}}

+

{{ 'addon.badges.claimid' | translate}}

{{ badge.endorsement.claimid }}

-

{{ 'addon.badges.claimcomment' | translate}}

+

{{ 'addon.badges.claimcomment' | translate}}

{{ badge.endorsement.claimcomment }}

@@ -214,12 +214,12 @@

{{ 'addon.badges.relatedbages' | translate}}

-

{{ relatedBadge.name }}

+

{{ relatedBadge.name }}

-

{{ 'addon.badges.norelated' | translate}}

+

{{ 'addon.badges.norelated' | translate}}

@@ -234,12 +234,12 @@

{{ 'addon.badges.alignment' | translate}}

-

{{ alignment.targetname }}

+

{{ alignment.targetname }}

-

{{ 'addon.badges.noalignment' | translate}}

+

{{ 'addon.badges.noalignment' | translate}}

diff --git a/src/addons/blog/pages/entries/entries.html b/src/addons/blog/pages/entries/entries.html index 06d7039325c..44d416a2a46 100644 --- a/src/addons/blog/pages/entries/entries.html +++ b/src/addons/blog/pages/entries/entries.html @@ -27,19 +27,22 @@

{{ title | translate }}

-

- - - +

+

+ + +

+ {{ 'addon.blog.' + entry.publishTranslated! | translate}} -

-

- +

+
+ {{entry.user && entry.user.fullname}} + {{entry.created | coreDateDayOrTime}} - {{entry.user && entry.user!.fullname}} -

+
diff --git a/src/addons/calendar/components/calendar/calendar.scss b/src/addons/calendar/components/calendar/calendar.scss index aea32bb2cf6..0df183e400e 100644 --- a/src/addons/calendar/components/calendar/calendar.scss +++ b/src/addons/calendar/components/calendar/calendar.scss @@ -98,7 +98,7 @@ .addon-calendar-period { flex-grow: 3; - h3 { + h2 { margin-top: 10px; font-size: 1.2rem; } diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html index 1abcf2c1678..8607da1549d 100644 --- a/src/addons/calendar/pages/day/day.html +++ b/src/addons/calendar/pages/day/day.html @@ -38,7 +38,7 @@

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

-

{{ periodName }}

+

{{ periodName }}

diff --git a/src/addons/calendar/pages/day/day.scss b/src/addons/calendar/pages/day/day.scss index 145eccfb8aa..111ba695370 100644 --- a/src/addons/calendar/pages/day/day.scss +++ b/src/addons/calendar/pages/day/day.scss @@ -6,7 +6,7 @@ .addon-calendar-period { flex-grow: 3; - h3 { + h2 { margin-top: 10px; font-size: 1.2rem; } diff --git a/src/addons/calendar/pages/event/event.html b/src/addons/calendar/pages/event/event.html index e41c029cc2c..40adfcf4c9b 100644 --- a/src/addons/calendar/pages/event/event.html +++ b/src/addons/calendar/pages/event/event.html @@ -60,7 +60,7 @@

-

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

+

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

@@ -70,13 +70,13 @@

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

-

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

+

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

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

-

{{ 'core.course' | translate}}

+

{{ 'core.course' | translate}}

@@ -85,13 +85,13 @@

{{ 'core.course' | translate}}

-

{{ 'core.group' | translate}}

+

{{ 'core.group' | translate}}

{{ groupName }}

-

{{ 'core.category' | translate}}

+

{{ 'core.category' | translate}}

@@ -100,7 +100,7 @@

{{ 'core.category' | translate}}

-

{{ 'core.description' | translate}}

+

{{ 'core.description' | translate}}

@@ -109,7 +109,7 @@

{{ 'core.description' | translate}}

-

{{ 'core.location' | translate}}

+

{{ 'core.location' | translate}}

-

{{ user.fullname }}

+

{{ user.fullname }}

@@ -115,7 +115,7 @@

{{ user.fullname }}

-

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

+

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

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

diff --git a/src/addons/competency/pages/coursecompetencies/coursecompetencies.html b/src/addons/competency/pages/coursecompetencies/coursecompetencies.html index daea9587959..3d36e23219e 100644 --- a/src/addons/competency/pages/coursecompetencies/coursecompetencies.html +++ b/src/addons/competency/pages/coursecompetencies/coursecompetencies.html @@ -53,7 +53,7 @@

-

{{ user.fullname }}

+

{{ user.fullname }}

diff --git a/src/addons/competency/pages/plan/plan.html b/src/addons/competency/pages/plan/plan.html index 0559725274a..ec42cb599c9 100644 --- a/src/addons/competency/pages/plan/plan.html +++ b/src/addons/competency/pages/plan/plan.html @@ -17,7 +17,7 @@

{{plan.plan.name}}

-

{{ user.fullname }}

+

{{ user.fullname }}

diff --git a/src/addons/coursecompletion/pages/report/report.html b/src/addons/coursecompletion/pages/report/report.html index e9c51650003..22420d5f91c 100644 --- a/src/addons/coursecompletion/pages/report/report.html +++ b/src/addons/coursecompletion/pages/report/report.html @@ -16,20 +16,20 @@

{{ 'addon.coursecompletion.coursecompletion' | translate }}

-

{{user!.fullname}}

+

{{user.fullname}}

-

{{ 'addon.coursecompletion.status' | translate }}

+

{{ 'addon.coursecompletion.status' | translate }}

{{ statusText! | translate }}

-

{{ 'addon.coursecompletion.required' | translate }}

+

{{ 'addon.coursecompletion.required' | translate }}

{{ 'addon.coursecompletion.criteriarequiredall' | translate }}

{{ 'addon.coursecompletion.criteriarequiredany' | translate }}

diff --git a/src/addons/messages/components/conversation-info/conversation-info.html b/src/addons/messages/components/conversation-info/conversation-info.html index c72e7b34413..38f7c081dd5 100644 --- a/src/addons/messages/components/conversation-info/conversation-info.html +++ b/src/addons/messages/components/conversation-info/conversation-info.html @@ -1,7 +1,7 @@ -

{{ 'addon.messages.groupinfo' | translate }}

+

{{ 'addon.messages.groupinfo' | translate }}

@@ -19,18 +19,18 @@

{{ 'addon.messages.groupinfo' | translate }}

-

- +

-

-

{{ 'addon.messages.numparticipants' | translate:{$a: conversation!.membercount} }}

+

{{ 'addon.messages.numparticipants' | translate:{$a: conversation.membercount} }}

diff --git a/src/addons/messages/pages/discussions-35/discussions.html b/src/addons/messages/pages/discussions-35/discussions.html index 63fa5426d87..9d80742f2fc 100644 --- a/src/addons/messages/pages/discussions-35/discussions.html +++ b/src/addons/messages/pages/discussions-35/discussions.html @@ -29,7 +29,7 @@

{{ 'addon.messages.messages' | translate }}

[attr.aria-label]="'addon.messages.contacts' | translate" detail="true" button> -

{{ 'addon.messages.contacts' | translate }}

+

{{ 'addon.messages.contacts' | translate }}

diff --git a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html index f24b5d9b36d..79f6fb07090 100644 --- a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html +++ b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html @@ -1,7 +1,7 @@ -

{{ plugin.name }}

+

{{ plugin.name }}

diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html index 170d86ab370..117b0c76df0 100644 --- a/src/addons/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -25,7 +25,7 @@ -

{{ 'core.course.hiddenfromstudents' | translate }}

+

{{ 'core.course.hiddenfromstudents' | translate }}

{{ 'core.no' | translate }}

{{ 'core.yes' | translate }}

@@ -33,13 +33,13 @@

{{ 'core.course.hiddenfromstudents' | translate }}

-

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

+

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

{{ timeRemaining }}

-

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

+

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

{{ lateSubmissions }}

@@ -47,8 +47,8 @@

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

-

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

-

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

+

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

+

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

@@ -66,7 +66,7 @@

{{ 'addon.mod_assign.numberofparticipants' | [class.hide-detail]="!summary.submissiondraftscount" [detail]="true" [button]="summary.submissiondraftscount" (click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)"> -

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

+

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

@@ -82,7 +82,7 @@

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

[class.hide-detail]="!summary.submissionssubmittedcount" [detail]="true" [button]="summary.submissionssubmittedcount" (click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)"> -

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

+

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

@@ -98,7 +98,7 @@

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

[class.hide-detail]="!needsGradingAvailable" [detail]="true" [button]="needsGradingAvailable" (click)="goToSubmissionList(needGrading, needsGradingAvailable)"> -

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

+

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

diff --git a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html index 2d8ec7e0796..122f5c79b74 100644 --- a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html @@ -15,7 +15,7 @@ [attr.aria-label]="user!.fullname"> -

{{ user!.fullname }}

+

{{ user!.fullname }}

@@ -23,7 +23,7 @@

{{ user!.fullname }}

-

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

+

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

@@ -31,7 +31,7 @@

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

-

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

+

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

@@ -44,7 +44,7 @@

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

-

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

+

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

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} @@ -59,7 +59,7 @@

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

-

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

+

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

@@ -77,7 +77,7 @@

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

-

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

+

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

{{ assign!.duedate * 1000 | coreFormatDate }}

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

@@ -85,14 +85,14 @@

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

-

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

+

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

{{ assign!.cutoffdate * 1000 | coreFormatDate }}

-

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

+

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

{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}

@@ -100,7 +100,7 @@

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

-

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

+

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

@@ -111,7 +111,7 @@

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

-

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

+

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

{{ assign.timelimit | coreDuration }}

@@ -120,7 +120,7 @@

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

-

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

+

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

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

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

@@ -130,7 +130,7 @@

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

-

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

+

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

{{ userSubmission!.timemodified * 1000 | coreFormatDate }}

@@ -151,7 +151,7 @@

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

[attr.aria-label]="user.fullname"> -

{{ user.fullname }}

+

{{ user.fullname }}

@@ -257,7 +257,7 @@

{{ user.fullname }}

-

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

+

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

@@ -273,7 +273,7 @@

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

Use a text input because otherwise we cannot readthe value if it has an invalid character. --> -

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

+

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

@@ -284,7 +284,7 @@

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

-

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

+

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

@@ -297,7 +297,7 @@

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

-

{{ outcome.name }}

+

{{ outcome.name }}

@@ -311,7 +311,7 @@

{{ outcome.name }}

-

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

+

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

{{ grade.gradebookGrade }}

@@ -332,7 +332,7 @@

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

-

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

+

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

{{ workflowStatusTranslationId | translate }}

@@ -340,7 +340,7 @@

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

-

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

+

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

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

@@ -350,7 +350,7 @@

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

-

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

+

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

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} @@ -376,8 +376,8 @@

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

[attr.aria-label]="grader!.fullname" detail="true"> -

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

-

{{ grader!.fullname }}

+

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

+

{{ grader!.fullname }}

{{ feedback!.gradeddate * 1000 | coreFormatDate }}

@@ -385,7 +385,7 @@

{{ grader!.fullname }}

-

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

+

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

{{ feedback!.gradeddate * 1000 | coreFormatDate }}

diff --git a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html index 549976d19cf..43f6b2009d1 100644 --- a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html +++ b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html @@ -20,7 +20,7 @@

{{ plugin.name }}

-

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

+

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

{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}

diff --git a/src/addons/mod/bigbluebuttonbn/components/index/index.html b/src/addons/mod/bigbluebuttonbn/components/index/index.html index 2fceda71527..782717ab024 100644 --- a/src/addons/mod/bigbluebuttonbn/components/index/index.html +++ b/src/addons/mod/bigbluebuttonbn/components/index/index.html @@ -19,13 +19,13 @@ -

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}

+

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}

{{ meetingInfo.openingtime * 1000 | coreFormatDate }}

-

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}

+

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}

{{ meetingInfo.closingtime * 1000 | coreFormatDate }}

@@ -45,31 +45,31 @@

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}

-

{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}

+

{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}

{{ meetingInfo.startedat * 1000 | coreFormatDate: "strftimetime" }}

-

+

{{ 'addon.mod_bigbluebuttonbn.view_message_moderators' | translate }} -

-

+

+

{{ 'addon.mod_bigbluebuttonbn.view_message_moderator' | translate }} -

+

{{ meetingInfo.moderatorcount }}

-

+

{{ 'addon.mod_bigbluebuttonbn.view_message_viewers' | translate }} -

-

+

+

{{ 'addon.mod_bigbluebuttonbn.view_message_viewer' | translate }} -

+

{{ meetingInfo.participantcount }}

@@ -108,7 +108,7 @@

{{ 'addon.mod_bigbluebuttonbn.view_section_title_recordings' | translate }}<
-

{{ data.label }}

+

{{ data.label }}

diff --git a/src/addons/mod/book/components/toc/toc.html b/src/addons/mod/book/components/toc/toc.html index 663370ab8e6..a8a12c97842 100644 --- a/src/addons/mod/book/components/toc/toc.html +++ b/src/addons/mod/book/components/toc/toc.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_book.toc' | translate }}

+

{{ 'addon.mod_book.toc' | translate }}

diff --git a/src/addons/mod/chat/components/users-modal/users-modal.html b/src/addons/mod/chat/components/users-modal/users-modal.html index b0b09c1407c..0f06b26d292 100644 --- a/src/addons/mod/chat/components/users-modal/users-modal.html +++ b/src/addons/mod/chat/components/users-modal/users-modal.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_chat.currentusers' | translate }}

+

{{ 'addon.mod_chat.currentusers' | translate }}

diff --git a/src/addons/mod/data/components/search/search.html b/src/addons/mod/data/components/search/search.html index e7dee1e2e5c..1c7d2d852eb 100644 --- a/src/addons/mod/data/components/search/search.html +++ b/src/addons/mod/data/components/search/search.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_data.search' | translate }}

+

{{ 'addon.mod_data.search' | translate }}

diff --git a/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html b/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html index d416650cab2..b8e281567eb 100644 --- a/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html +++ b/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html @@ -64,7 +64,7 @@ -

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

+

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

@@ -76,12 +76,12 @@

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

-

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

+

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

-

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

+

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

@@ -115,19 +115,19 @@

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

-

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

+

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

{{overview.openTimeReadable}}

-

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

+

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

{{overview.closeTimeReadable}}

-

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

+

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

@@ -136,7 +136,7 @@

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

-

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

+

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

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

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

diff --git a/src/addons/mod/feedback/pages/attempt/attempt.html b/src/addons/mod/feedback/pages/attempt/attempt.html index 41005c39334..0a110f49bc7 100644 --- a/src/addons/mod/feedback/pages/attempt/attempt.html +++ b/src/addons/mod/feedback/pages/attempt/attempt.html @@ -33,12 +33,12 @@

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

-

+

{{item.itemnumber}}. -

+

diff --git a/src/addons/mod/feedback/pages/form/form.html b/src/addons/mod/feedback/pages/form/form.html index e16c65d3967..9f07a02553e 100644 --- a/src/addons/mod/feedback/pages/form/form.html +++ b/src/addons/mod/feedback/pages/form/form.html @@ -17,7 +17,7 @@

-

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

+

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

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

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

diff --git a/src/addons/mod/forum/components/post/post.html b/src/addons/mod/forum/components/post/post.html index ab1f83cfe8f..9727242bafe 100644 --- a/src/addons/mod/forum/components/post/post.html +++ b/src/addons/mod/forum/components/post/post.html @@ -126,7 +126,7 @@

-

{{ 'addon.mod_forum.advanced' | translate }}

+

{{ 'addon.mod_forum.advanced' | translate }}

diff --git a/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html index 45ff2157789..54d488ebaea 100644 --- a/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html +++ b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html @@ -1,7 +1,7 @@ -

{{ 'core.sort' | translate }}

+

{{ 'core.sort' | translate }}

@@ -17,7 +17,7 @@

{{ 'core.sort' | translate }}

[attr.aria-current]="selected == sortOrder.value ? 'page' : 'false'" [attr.aria-label]="sortOrder.label | translate" (click)="selectSortOrder(sortOrder)" button aria-haspopup="dialog"> -

{{ sortOrder.label | translate }}

+

{{ sortOrder.label | translate }}

diff --git a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html index 41245df1ad1..b4bff993233 100644 --- a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html +++ b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -57,7 +57,7 @@ -

{{ progressMessage | translate }}

+

{{ progressMessage | translate }}

diff --git a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html index d40c535162e..efe7dd8c062 100644 --- a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html +++ b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html @@ -23,13 +23,13 @@

[attr.aria-label]="user.fullname"> -

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}

+

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}

-

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}

+

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}

@@ -38,13 +38,13 @@

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}

-

{{ 'addon.mod_h5pactivity.startdate' | translate }}

+

{{ 'addon.mod_h5pactivity.startdate' | translate }}

{{ attempt.timecreated | coreFormatDate:'strftimedatetime' }}

-

{{ 'addon.mod_h5pactivity.completion' | translate }}

+

{{ 'addon.mod_h5pactivity.completion' | translate }}

{{ 'addon.mod_h5pactivity.attempt_completion_yes' | translate }} @@ -57,13 +57,13 @@

{{ 'addon.mod_h5pactivity.completion' | translate }}

-

{{ 'addon.mod_h5pactivity.duration' | translate }}

+

{{ 'addon.mod_h5pactivity.duration' | translate }}

{{ attempt.durationReadable }}

-

{{ 'addon.mod_h5pactivity.outcome' | translate }}

+

{{ 'addon.mod_h5pactivity.outcome' | translate }}

{{ 'addon.mod_h5pactivity.attempt_success_pass' | translate }} @@ -79,7 +79,7 @@

{{ 'addon.mod_h5pactivity.outcome' | translate }}

-

{{ 'addon.mod_h5pactivity.totalscore' | translate }}

+

{{ 'addon.mod_h5pactivity.totalscore' | translate }}

{{ 'addon.mod_h5pactivity.score_out_of' | translate:{$a: attempt} }}

diff --git a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html index 30b181aca84..c9030e75deb 100644 --- a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html +++ b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html @@ -37,7 +37,7 @@

{{ 'addon.mod_h5pactivity.myattempts' | translate }}

-

{{ attemptsData.scored.title }}

+

{{ attemptsData.scored.title }}

@@ -48,7 +48,7 @@

{{ attemptsData.scored.title }}

-

{{ 'addon.mod_h5pactivity.all_attempts' | translate }}

+

{{ 'addon.mod_h5pactivity.all_attempts' | translate }}

diff --git a/src/addons/mod/imscp/components/toc/toc.html b/src/addons/mod/imscp/components/toc/toc.html index e9aee76c64f..6a0565fc0cf 100644 --- a/src/addons/mod/imscp/components/toc/toc.html +++ b/src/addons/mod/imscp/components/toc/toc.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_imscp.toc' | translate }}

+

{{ 'addon.mod_imscp.toc' | translate }}

diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 2ed4ab3a183..104ab736417 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -77,7 +77,7 @@ -

{{ 'addon.mod_lesson.averagescore' | translate }}

+

{{ 'addon.mod_lesson.averagescore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}

@@ -85,7 +85,7 @@

{{ 'addon.mod_lesson.averagescore' | translate }}

-

{{ 'addon.mod_lesson.highscore' | translate }}

+

{{ 'addon.mod_lesson.highscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}

@@ -93,7 +93,7 @@

{{ 'addon.mod_lesson.highscore' | translate }}

-

{{ 'addon.mod_lesson.lowscore' | translate }}

+

{{ 'addon.mod_lesson.lowscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}

@@ -102,7 +102,7 @@

{{ 'addon.mod_lesson.lowscore' | translate }}

-

{{ 'addon.mod_lesson.averagetime' | translate }}

+

{{ 'addon.mod_lesson.averagetime' | translate }}

{{ avetimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }} @@ -110,13 +110,13 @@

{{ 'addon.mod_lesson.averagetime' | translate }}

-

{{ 'addon.mod_lesson.hightime' | translate }}

+

{{ 'addon.mod_lesson.hightime' | translate }}

{{ hightimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

-

{{ 'addon.mod_lesson.lowtime' | translate }}

+

{{ 'addon.mod_lesson.lowtime' | translate }}

{{ lowtimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

@@ -127,7 +127,7 @@

{{ 'addon.mod_lesson.lowtime' | translate }}

-

{{ 'addon.mod_lesson.averagescore' | translate }}

+

{{ 'addon.mod_lesson.averagescore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}

@@ -135,7 +135,7 @@

{{ 'addon.mod_lesson.averagescore' | translate }}

-

{{ 'addon.mod_lesson.averagetime' | translate }}

+

{{ 'addon.mod_lesson.averagetime' | translate }}

{{ avetimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }} @@ -144,7 +144,7 @@

{{ 'addon.mod_lesson.averagetime' | translate }}

-

{{ 'addon.mod_lesson.highscore' | translate }}

+

{{ 'addon.mod_lesson.highscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}

@@ -152,14 +152,14 @@

{{ 'addon.mod_lesson.highscore' | translate }}

-

{{ 'addon.mod_lesson.hightime' | translate }}

+

{{ 'addon.mod_lesson.hightime' | translate }}

{{ hightimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

-

{{ 'addon.mod_lesson.lowscore' | translate }}

+

{{ 'addon.mod_lesson.lowscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}

@@ -167,7 +167,7 @@

{{ 'addon.mod_lesson.lowscore' | translate }}

-

{{ 'addon.mod_lesson.lowtime' | translate }}

+

{{ 'addon.mod_lesson.lowtime' | translate }}

{{ lowtimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

diff --git a/src/addons/mod/lesson/components/menu-modal/menu-modal.html b/src/addons/mod/lesson/components/menu-modal/menu-modal.html index 80e242f0b05..a0a1c2cb769 100644 --- a/src/addons/mod/lesson/components/menu-modal/menu-modal.html +++ b/src/addons/mod/lesson/components/menu-modal/menu-modal.html @@ -1,7 +1,7 @@ -

{{ pageInstance?.lesson?.name }}

+

{{ pageInstance?.lesson?.name }}

diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html index 1f033719b35..5bcc9b3751a 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.html +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -1,7 +1,7 @@ -

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

+

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

diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index 9613763db44..6d8e6e8dcb1 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -94,7 +94,7 @@

-

{{ 'addon.mod_lesson.youranswer' | translate }}

+

{{ 'addon.mod_lesson.youranswer' | translate }}

{{student.fullname}}

-

{{ 'addon.mod_lesson.grade' | translate }}

+

{{ 'addon.mod_lesson.grade' | translate }}

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

-

{{ 'addon.mod_lesson.rawgrade' | translate }}

+

{{ 'addon.mod_lesson.rawgrade' | translate }}

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

@@ -57,13 +57,13 @@

{{ 'addon.mod_lesson.rawgrade' | translate }}

-

{{ 'addon.mod_lesson.timetaken' | translate }}

+

{{ 'addon.mod_lesson.timetaken' | translate }}

{{ timeTakenReadable }}

-

{{ 'addon.mod_lesson.completed' | translate }}

+

{{ 'addon.mod_lesson.completed' | translate }}

{{ retake.userstats.completed * 1000 | coreFormatDate }}

@@ -84,7 +84,7 @@

{{ 'addon.mod_lesson.completed' | translate }}

-

{{ 'addon.mod_lesson.question' | translate }}

+

{{ 'addon.mod_lesson.question' | translate }}

{{ 'addon.mod_lesson.question' | translate }}

-

{{ 'addon.mod_lesson.answer' | translate }}

+

{{ 'addon.mod_lesson.answer' | translate }}

@@ -226,7 +226,7 @@

{{ 'addon.mod_lesson.answer' | translate }}

-

{{ 'addon.mod_lesson.response' | translate }}

+

{{ 'addon.mod_lesson.response' | translate }}

-

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

+

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

{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}

diff --git a/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html b/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html index 9b713053ea4..ed53f0e8e76 100644 --- a/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html +++ b/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html @@ -1,6 +1,6 @@ -

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

+

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

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

diff --git a/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html b/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html index b48075dbfb3..6cd54c1e86c 100644 --- a/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html +++ b/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html @@ -1,6 +1,6 @@ -

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

+

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

{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}

diff --git a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html index 239b919fa2e..0f4b70459c0 100644 --- a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html +++ b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html @@ -1,7 +1,7 @@ -

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

+

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

diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html index c642e6cc914..effcffa7644 100644 --- a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html @@ -1,7 +1,7 @@ -

{{ title | translate }}

+

{{ title | translate }}

diff --git a/src/addons/mod/quiz/pages/attempt/attempt.html b/src/addons/mod/quiz/pages/attempt/attempt.html index e7dfbdeadee..9227f2e1f6a 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.html +++ b/src/addons/mod/quiz/pages/attempt/attempt.html @@ -20,32 +20,32 @@

-

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

+

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

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

{{ attempt.attempt }}

-

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

+

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

{{ sentence }}

-

{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}

+

{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}

{{ attempt.readableMark }}

-

{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}

+

{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}

{{ attempt.readableGrade }}

-

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

+

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

diff --git a/src/addons/mod/quiz/pages/player/player.html b/src/addons/mod/quiz/pages/player/player.html index dd42c0bc522..2e4b6f12031 100644 --- a/src/addons/mod/quiz/pages/player/player.html +++ b/src/addons/mod/quiz/pages/player/player.html @@ -132,7 +132,7 @@

{{ 'core.question.information' | tra -

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

+

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

{{message}}

diff --git a/src/addons/mod/quiz/pages/review/review.html b/src/addons/mod/quiz/pages/review/review.html index fc90cf8553a..12e0feb232f 100644 --- a/src/addons/mod/quiz/pages/review/review.html +++ b/src/addons/mod/quiz/pages/review/review.html @@ -26,43 +26,43 @@

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

-

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

+

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

{{ attempt.timestart! * 1000 | coreFormatDate }}

-

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

+

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

{{ readableState }}

-

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

+

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

{{ attempt.timefinish! * 1000 | coreFormatDate }}

-

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

+

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

{{ timeTaken }}

-

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

+

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

{{ overTime }}

-

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

+

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

{{ readableMark }}

-

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

+

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

{{ readableGrade }}

diff --git a/src/addons/mod/resource/components/index/addon-mod-resource-index.html b/src/addons/mod/resource/components/index/addon-mod-resource-index.html index 020ba6eea6a..3a7823f5453 100644 --- a/src/addons/mod/resource/components/index/addon-mod-resource-index.html +++ b/src/addons/mod/resource/components/index/addon-mod-resource-index.html @@ -32,7 +32,7 @@ -

{{ 'core.type' | translate }}

+

{{ 'core.type' | translate }}

{{ type }}

@@ -40,28 +40,28 @@

{{ 'core.type' | translate }}

-

{{ 'core.size' | translate }}

+

{{ 'core.size' | translate }}

{{ readableSize }}

-

{{ 'core.datecreated' | translate }}

+

{{ 'core.datecreated' | translate }}

{{ timecreated | coreFormatDate }}

-

{{ 'core.lastmodified' | translate }}

+

{{ 'core.lastmodified' | translate }}

{{ timemodified | coreFormatDate }}

-

{{ 'core.lastdownloaded' | translate }}

+

{{ 'core.lastdownloaded' | translate }}

{{ downloadTimeReadable }}

diff --git a/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html b/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html index d2b0c257f74..de077cdb957 100644 --- a/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html +++ b/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html @@ -214,7 +214,7 @@

{{'core.grades.grades' | translate}}

-

{{ progressMessage | translate }}

+

{{ progressMessage | translate }}

diff --git a/src/addons/mod/scorm/components/toc/toc.html b/src/addons/mod/scorm/components/toc/toc.html index 01163c6fcda..224f8270cac 100644 --- a/src/addons/mod/scorm/components/toc/toc.html +++ b/src/addons/mod/scorm/components/toc/toc.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_scorm.toc' | translate }}

+

{{ 'addon.mod_scorm.toc' | translate }}

diff --git a/src/addons/mod/url/components/index/addon-mod-url-index.html b/src/addons/mod/url/components/index/addon-mod-url-index.html index 0e7fa1baa05..85519119458 100644 --- a/src/addons/mod/url/components/index/addon-mod-url-index.html +++ b/src/addons/mod/url/components/index/addon-mod-url-index.html @@ -31,7 +31,7 @@
-

{{ 'addon.mod_url.pointingtourl' | translate }}

+

{{ 'addon.mod_url.pointingtourl' | translate }}

{{ url }}

diff --git a/src/addons/mod/wiki/components/map/map.html b/src/addons/mod/wiki/components/map/map.html index c8c1a44cb77..f0cb4d57705 100644 --- a/src/addons/mod/wiki/components/map/map.html +++ b/src/addons/mod/wiki/components/map/map.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_wiki.map' | translate }}

+

{{ 'addon.mod_wiki.map' | translate }}

diff --git a/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html b/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html index aa7de86e636..866a50f80fe 100644 --- a/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html +++ b/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html @@ -2,7 +2,7 @@ -

{{ group.label }}

+

{{ group.label }}

+
@@ -22,7 +22,8 @@

{{ field.dimtitle }}

-

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translate : {'$a': field.dimtitle } }}

+

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translate : {'$a': + field.dimtitle } }}

{{grade.label}}

@@ -37,9 +38,9 @@

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translat -

+

{{ 'addon.mod_workshop_assessment_accumulative.dimensioncommentfor' | translate : {'$a': field.dimtitle } }} -

+

@@ -48,4 +49,4 @@

- +

diff --git a/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html b/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html index 4badf862d2c..974b0deb634 100644 --- a/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html +++ b/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html @@ -1,4 +1,4 @@ - +
@@ -20,7 +20,8 @@

{{ field.dimtitle }}

-

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate : {'$a': field.dimtitle } }}

+

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate : {'$a': field.dimtitle + } }}

@@ -29,4 +30,4 @@

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate - +

diff --git a/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html b/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html index b742587b42a..33a93fc1442 100644 --- a/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html +++ b/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html @@ -1,4 +1,4 @@ - +
@@ -42,7 +42,8 @@

{{ field.dimtitle }}

-

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate : {'$a': field.dimtitle } }}

+

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate : {'$a': field.dimtitle + } }}

@@ -51,4 +52,4 @@

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate - +

diff --git a/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html b/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html index 1920e85c8be..58fa080bad8 100644 --- a/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html +++ b/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html @@ -1,4 +1,4 @@ - +
@@ -24,4 +24,4 @@

{{ field.dimtitle }}

- +
diff --git a/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html b/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html index 1f3111ee3c7..160f9e4f041 100644 --- a/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html +++ b/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html @@ -1,4 +1,4 @@ -

{{ 'addon.mod_workshop.assessmentform' | translate }}

+

{{ 'addon.mod_workshop.assessmentform' | translate }}

@@ -18,7 +18,7 @@

{{ 'addon.mod_workshop.assessmentform' | translate }} -

{{ 'addon.mod_workshop.overallfeedback' | translate }}

+

{{ 'addon.mod_workshop.overallfeedback' | translate }}

diff --git a/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html index 434e6f5a2a0..7487a611876 100644 --- a/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html +++ b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html @@ -31,7 +31,7 @@

{{ phases[workshop!.phase].title }}

-

{{task.title}}

+

{{task.title}}

@@ -125,7 +125,7 @@

-

{{ 'addon.mod_workshop.publishedsubmissions' | translate }}

+

{{ 'addon.mod_workshop.publishedsubmissions' | translate }}

@@ -141,7 +141,7 @@

{{ 'addon.mod_workshop.publishedsubmissions' | translate }}

-

{{ 'addon.mod_workshop.areainstructreviewers' | translate }}

+

{{ 'addon.mod_workshop.areainstructreviewers' | translate }}

@@ -153,7 +153,7 @@

{{ 'addon.mod_workshop.areainstructreviewers' | translate }}

-

{{ 'addon.mod_workshop.assignedassessments' | translate }}

+

{{ 'addon.mod_workshop.assignedassessments' | translate }}

@@ -175,8 +175,10 @@

{{ 'addon.mod_workshop.assignedassessments' | translate }}

((grades && grades.length) || (groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)))"> -

{{ 'addon.mod_workshop.submissionsreport' | translate }}

-

{{ 'addon.mod_workshop.gradesreport' | translate }}

+

{{ 'addon.mod_workshop.submissionsreport' | + translate }}

+

{{ 'addon.mod_workshop.gradesreport' | translate }} +

diff --git a/src/addons/mod/workshop/components/phase/phase.html b/src/addons/mod/workshop/components/phase/phase.html index 407551e370b..c9720ceb79d 100644 --- a/src/addons/mod/workshop/components/phase/phase.html +++ b/src/addons/mod/workshop/components/phase/phase.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_workshop.userplan' | translate }}

+

{{ 'addon.mod_workshop.userplan' | translate }}

@@ -40,7 +40,7 @@

{{ phase.title }}

-

{{task.title}}

+

{{task.title}}

-

{{ 'addon.mod_workshop.feedbackauthor' | translate }}

+

{{ 'addon.mod_workshop.feedbackauthor' | translate }}

@@ -112,7 +112,7 @@

{{ 'addon.mod_workshop.feedbackauthor' | translate }}

-

{{ 'addon.mod_workshop.gradecalculated' | translate }}

+

{{ 'addon.mod_workshop.gradecalculated' | translate }}

{{ submission.grade }}

@@ -142,9 +142,9 @@

{{ 'addon.mod_workshop.gradecalculated' | translate }}

-

+

{{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateGradingByProfile.fullname} }} -

+

diff --git a/src/addons/notes/components/add/add-modal.html b/src/addons/notes/components/add/add-modal.html index e3cb5c8580a..55a6feeedc1 100644 --- a/src/addons/notes/components/add/add-modal.html +++ b/src/addons/notes/components/add/add-modal.html @@ -1,7 +1,7 @@ -

{{ 'addon.notes.addnewnote' | translate }}

+

{{ 'addon.notes.addnewnote' | translate }}

diff --git a/src/addons/notes/pages/list/list.html b/src/addons/notes/pages/list/list.html index 6c58feff3bb..af931bd96c5 100644 --- a/src/addons/notes/pages/list/list.html +++ b/src/addons/notes/pages/list/list.html @@ -34,7 +34,7 @@

{{ 'addon.notes.notes' | translate }}

-

{{user!.fullname}}

+

{{user!.fullname}}

diff --git a/src/addons/storagemanager/pages/course-storage/course-storage.html b/src/addons/storagemanager/pages/course-storage/course-storage.html index 8ec983aabd7..c8a243275ca 100644 --- a/src/addons/storagemanager/pages/course-storage/course-storage.html +++ b/src/addons/storagemanager/pages/course-storage/course-storage.html @@ -108,11 +108,11 @@

{{ 'addon.storagemanager.coursedownloads' | translate }}

[modname]="module.modname" [componentId]="module.instance"> -

+

-

+

-

{{ field.name }}

+

{{ field.name }}

{{ 'core.yes' | translate }}

@@ -19,4 +19,4 @@

{{ field.name }}

-
\ No newline at end of file +
diff --git a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html index bf48afb57a5..19b3d16de91 100644 --- a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html +++ b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

{{ valueNumber * 1000 | coreFormatDate }}

diff --git a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html index 3816c2090b0..a657ef6c1e3 100644 --- a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html +++ b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

diff --git a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html index 4ee87abe64c..2426c2dd378 100644 --- a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html +++ b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index bd1a4189133..ff938e96e0f 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

diff --git a/src/core/components/tabs-outlet/core-tabs-outlet.html b/src/core/components/tabs-outlet/core-tabs-outlet.html index b820e9c7235..8933706b0bf 100644 --- a/src/core/components/tabs-outlet/core-tabs-outlet.html +++ b/src/core/components/tabs-outlet/core-tabs-outlet.html @@ -16,7 +16,7 @@ [tabindex]="selected == tab.id ? 0 : -1"> - {{ tab.title | translate}} +

{{ tab.title | translate}}

{{ tab.badge }} diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index 26f990e080a..9ea65bdedb1 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -14,7 +14,7 @@ [attr.aria-selected]="selected == tab.id" [tabindex]="selected == tab.id ? 0 : -1"> - {{ tab.title | translate}} +

{{ tab.title | translate}}

{{ tab.badge }} diff --git a/src/core/components/tabs/tabs.scss b/src/core/components/tabs/tabs.scss index 69d09150f04..26eebb6a935 100644 --- a/src/core/components/tabs/tabs.scss +++ b/src/core/components/tabs/tabs.scss @@ -59,14 +59,16 @@ ion-tab-button { max-width: 100%; ion-label { - font-size: 14px; - font-weight: 400; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; word-wrap: break-word; max-width: 100%; line-height: 1.2em; + h2 { + font-size: 14px; + font-weight: 400; + } } } diff --git a/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html b/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html index f166b4111a5..efb48a855ad 100644 --- a/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html +++ b/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html @@ -1,7 +1,7 @@ -

{{ 'core.contentlinks.chooseaccount' | translate }}

+

{{ 'core.contentlinks.chooseaccount' | translate }}

@@ -15,7 +15,7 @@

{{ 'core.contentlinks.chooseaccount' | translate }}

-

{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}

+

{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}

{{ url }}

diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html index 7db7e5d2d28..16e6aca9e11 100644 --- a/src/core/features/course/components/course-format/course-format.html +++ b/src/core/features/course/components/course-format/course-format.html @@ -65,10 +65,11 @@
+ class="core-course-module-list-wrapper" [id]="section.id" + [attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null"> -

+

diff --git a/src/core/features/course/components/course-index/course-index.html b/src/core/features/course/components/course-index/course-index.html index 38ffbf7f3e6..b833477fd9a 100644 --- a/src/core/features/course/components/course-index/course-index.html +++ b/src/core/features/course/components/course-index/course-index.html @@ -1,7 +1,7 @@ -

{{ 'core.course.courseindex' | translate }}

+

{{ 'core.course.courseindex' | translate }}

diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html index 45b60f5e3c6..58a856f2496 100644 --- a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html @@ -1,7 +1,7 @@ -

{{ 'core.courses.selfenrolment' | translate }}

+

{{ 'core.courses.selfenrolment' | translate }}

diff --git a/src/core/features/courses/pages/categories/categories.html b/src/core/features/courses/pages/categories/categories.html index da1c214e4f0..c4c600bc10a 100644 --- a/src/core/features/courses/pages/categories/categories.html +++ b/src/core/features/courses/pages/categories/categories.html @@ -51,10 +51,10 @@

{{ 'core.courses.categories' | translate }}

detail="true"> -

+

-

+

diff --git a/src/core/features/login/components/site-help/site-help.html b/src/core/features/login/components/site-help/site-help.html index 25304a89a14..21ce3f3348a 100644 --- a/src/core/features/login/components/site-help/site-help.html +++ b/src/core/features/login/components/site-help/site-help.html @@ -1,7 +1,7 @@ -

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

+

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

@@ -14,7 +14,7 @@

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

-

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

+

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

@@ -24,7 +24,7 @@

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

-

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

+

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

@@ -34,7 +34,7 @@

{{ 'core.login.faqcannotfindmysitequestion' | translate }}< -

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

+

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

@@ -47,7 +47,7 @@

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

-

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

+

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

@@ -57,7 +57,7 @@

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

-

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

+

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

-

{{'core.login.onboardingwelcome' | translate}} -

+

-

+

-

+

{{siteUrl}}

@@ -78,7 +78,7 @@

@@ -32,7 +32,7 @@

detail="true" [attr.aria-label]="'core.user.profile' | translate"> -

{{ siteInfo.fullname }}

+

{{ siteInfo.fullname }}

diff --git a/src/core/features/rating/components/ratings/ratings-modal.html b/src/core/features/rating/components/ratings/ratings-modal.html index 484398ae5a8..2cb53b2881c 100644 --- a/src/core/features/rating/components/ratings/ratings-modal.html +++ b/src/core/features/rating/components/ratings/ratings-modal.html @@ -1,7 +1,7 @@ -

{{ 'core.rating.ratings' | translate }}

+

{{ 'core.rating.ratings' | translate }}

@@ -16,7 +16,7 @@

{{ 'core.rating.ratings' | translate }}

-

{{ rating.userfullname }}

+

{{ rating.userfullname }}

{{ rating.rating }}

diff --git a/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html index 023ea6791ce..a3b7baf4ed4 100644 --- a/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html +++ b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html @@ -1,7 +1,7 @@ -

{{ 'core.reminders.customreminder' | translate }}

+

{{ 'core.reminders.customreminder' | translate }}

diff --git a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html index c7d1c24e815..45a4bd24143 100644 --- a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html +++ b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html @@ -1,7 +1,7 @@ -

{{ 'core.reminders.setareminder' | translate }}

+

{{ 'core.reminders.setareminder' | translate }}

diff --git a/src/core/features/reportbuilder/components/report-column/report-column.html b/src/core/features/reportbuilder/components/report-column/report-column.html index c5ee3a6ff79..b5799edb301 100644 --- a/src/core/features/reportbuilder/components/report-column/report-column.html +++ b/src/core/features/reportbuilder/components/report-column/report-column.html @@ -2,8 +2,11 @@ [attr.aria-controls]="'core-report-builder-column-' + rowIndex" [attr.aria-label]="(isExpanded ? 'core.hidecolumns' : 'core.showcolumns') | translate" (click)="toggleRow()"> -

{{ header }}

- +

{{ header }}

+

+ +

+

- {{ 'core.settings.developeroptions' | translate }} +

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

-

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

+

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

{{ deviceInfo.versionName }} ({{ deviceInfo.versionCode }})

-

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

+

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

{{ deviceInfo.compilationTime | coreFormatDate: "LLL Z": false }}

{{ deviceInfo.lastCommit }}

-

{{ 'core.settings.siteinfo' | translate }} * -

+

{{ 'core.settings.siteinfo' | translate }} + * +

{{ deviceInfo.siteUrl }}

{{ deviceInfo.siteVersion }}

{{ deviceInfo.siteId }}

@@ -48,7 +49,7 @@

{{ 'core.settings.siteinfo' | translate }} -

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

+

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

{{ deviceInfo.fileSystemRoot }} @@ -59,97 +60,97 @@

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

-

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

+

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

{{ deviceInfo.userAgent }}

-

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

+

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

{{ deviceInfo.browserLanguage }}

-

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

+

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

{{ currentLangName }} ({{ deviceInfo.currentLanguage }})

-

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

+

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

{{ deviceInfo.locationHref }}

-

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

+

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

{{ 'core.' + deviceInfo.deviceType | translate }}

-

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

+

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

{{ deviceOsTranslated }}

-

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

+

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

{{ deviceInfo.screen }}

-

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

+

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

{{ 'core.' + deviceInfo.networkStatus | translate }}

-

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

+

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

{{ 'core.' + deviceInfo.wifiConnection | translate }}

-

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

+

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

{{ deviceInfo.cordovaVersion }}

-

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

+

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

{{ deviceInfo.platform }}

-

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

+

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

{{ deviceInfo.osVersion }}

-

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

+

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

{{ deviceInfo.model }}

-

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

+

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

{{ deviceInfo.uuid }}

-

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

+

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

{{ deviceInfo.pushId }}

-

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

+

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

{{ 'core.' + deviceInfo.localNotifAvailable | translate }}

diff --git a/src/core/features/settings/pages/general/general.html b/src/core/features/settings/pages/general/general.html index c3de108b35d..9cc006e5710 100644 --- a/src/core/features/settings/pages/general/general.html +++ b/src/core/features/settings/pages/general/general.html @@ -13,7 +13,7 @@

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

-

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

+

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

@@ -22,7 +22,7 @@

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

-

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

+

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

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

-

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

+

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

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

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

-

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

+

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

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

-

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

+

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

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

{{ 'core.opensettings' | translate }} @@ -69,14 +69,14 @@

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

-

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

+

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

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

-

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

+

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

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

diff --git a/src/core/features/settings/pages/space-usage/space-usage.html b/src/core/features/settings/pages/space-usage/space-usage.html index ec3524adb10..0f5040f7521 100644 --- a/src/core/features/settings/pages/space-usage/space-usage.html +++ b/src/core/features/settings/pages/space-usage/space-usage.html @@ -57,7 +57,7 @@

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

-

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

+

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

{{ totalSpaceUsage | coreBytesToSize }} diff --git a/src/core/features/sharedfiles/components/list-modal/list-modal.html b/src/core/features/sharedfiles/components/list-modal/list-modal.html index 682496ad493..8fe9bb42a87 100644 --- a/src/core/features/sharedfiles/components/list-modal/list-modal.html +++ b/src/core/features/sharedfiles/components/list-modal/list-modal.html @@ -1,7 +1,7 @@ -

{{ title }}

+

{{ title }}

diff --git a/src/core/features/sitehome/pages/index/index.html b/src/core/features/sitehome/pages/index/index.html index b780c5565e7..62f5682f8ac 100644 --- a/src/core/features/sitehome/pages/index/index.html +++ b/src/core/features/sitehome/pages/index/index.html @@ -59,7 +59,7 @@ -

{{ 'core.courses.availablecourses' | translate}}

+

{{ 'core.courses.availablecourses' | translate}}

@@ -75,7 +75,7 @@

{{ 'core.courses.availablecourses' | translate}}

-

{{ 'core.courses.categories' | translate}}

+

{{ 'core.courses.categories' | translate}}

@@ -87,7 +87,7 @@

{{ 'core.courses.categories' | translate}}

-

{{ 'core.courses.mycourses' | translate}}

+

{{ 'core.courses.mycourses' | translate}}

@@ -98,7 +98,7 @@

{{ 'core.courses.mycourses' | translate}}

-

{{ 'core.courses.searchcourses' | translate}}

+

{{ 'core.courses.searchcourses' | translate}}

diff --git a/src/core/features/tag/components/feed/core-tag-feed.html b/src/core/features/tag/components/feed/core-tag-feed.html index 9e8f4430953..17b3de5ca41 100644 --- a/src/core/features/tag/components/feed/core-tag-feed.html +++ b/src/core/features/tag/components/feed/core-tag-feed.html @@ -6,7 +6,7 @@ -

{{ item.heading }}

+

{{ item.heading }}

{{ text }}

diff --git a/src/core/features/tag/pages/index/index.html b/src/core/features/tag/pages/index/index.html index 151b3f93f48..2a07a407416 100644 --- a/src/core/features/tag/pages/index/index.html +++ b/src/core/features/tag/pages/index/index.html @@ -21,13 +21,13 @@

{{ 'core.tag.tag' | translate }}: {{ tagName }}

{{ 'core.tag.warningareasnotsupported' | translate }} + (click)="openArea(area)" [attr.aria-current]="area.id == selectedAreaId ? 'page' : 'false'" button detail="true"> -

{{ area!.nameKey | translate }}

+

{{ area.nameKey | translate }}

- - - {{ 'core.tag.tagareabadgedescription' | translate:{ count: area!.badge } }} + + + {{ 'core.tag.tagareabadgedescription' | translate:{ count: area.badge } }}
diff --git a/src/core/features/user/components/tag-area/core-user-tag-area.html b/src/core/features/user/components/tag-area/core-user-tag-area.html index 3531973e08c..ccba8b62643 100644 --- a/src/core/features/user/components/tag-area/core-user-tag-area.html +++ b/src/core/features/user/components/tag-area/core-user-tag-area.html @@ -2,7 +2,7 @@ -

{{ item.heading }}

+

{{ item.heading }}

diff --git a/src/core/features/user/pages/about/about.html b/src/core/features/user/pages/about/about.html index 751d3dd524d..bf0f04d8126 100644 --- a/src/core/features/user/pages/about/about.html +++ b/src/core/features/user/pages/about/about.html @@ -32,12 +32,12 @@

{{ user.fullname }}

-

{{ 'core.user.contact' | translate}}

+

{{ 'core.user.contact' | translate}}

-

{{ 'core.user.email' | translate }}

+

{{ 'core.user.email' | translate }}

{{ user.email }}

@@ -45,7 +45,7 @@

{{ 'core.user.email' | translate }}

-

{{ 'core.user.phone1' | translate}}

+

{{ 'core.user.phone1' | translate}}

{{ user.phone1 }}

@@ -53,7 +53,7 @@

{{ 'core.user.phone1' | translate}}

-

{{ 'core.user.phone2' | translate}}

+

{{ 'core.user.phone2' | translate}}

{{ user.phone2 }}

@@ -61,7 +61,7 @@

{{ 'core.user.phone2' | translate}}

-

{{ 'core.user.address' | translate}}

+

{{ 'core.user.address' | translate}}

{{ formattedAddress }}

@@ -69,13 +69,13 @@

{{ 'core.user.address' | translate}}

-

{{ 'core.user.city' | translate}}

+

{{ 'core.user.city' | translate}}

{{ user.city }}

-

{{ 'core.user.country' | translate}}

+

{{ 'core.user.country' | translate}}

{{ user.country }}

@@ -83,12 +83,12 @@

{{ 'core.user.country' | translate}}

-

{{ 'core.userdetails' | translate}}

+

{{ 'core.userdetails' | translate}}

-

{{ 'core.user.webpage' | translate}}

+

{{ 'core.user.webpage' | translate}}

{{ user.url }}

@@ -96,7 +96,7 @@

{{ 'core.user.webpage' | translate}}