From cad69753ef9f01fa3b53f45142fe85be765138ed Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 14 Dec 2020 16:11:00 +0100 Subject: [PATCH 1/7] MOBILE-3666 h5p: Add lang and assets --- .../features/h5p/assets/fonts/h5p-core-23.eot | Bin 0 -> 9224 bytes .../features/h5p/assets/fonts/h5p-core-23.svg | 62 + .../features/h5p/assets/fonts/h5p-core-23.ttf | Bin 0 -> 9044 bytes .../h5p/assets/fonts/h5p-core-23.woff | Bin 0 -> 9120 bytes .../features/h5p/assets/js/h5p-action-bar.js | 100 + .../h5p/assets/js/h5p-confirmation-dialog.js | 410 +++ .../h5p/assets/js/h5p-content-type.js | 41 + .../assets/js/h5p-content-upgrade-process.js | 313 ++ .../assets/js/h5p-content-upgrade-worker.js | 63 + .../h5p/assets/js/h5p-content-upgrade.js | 445 +++ .../features/h5p/assets/js/h5p-data-view.js | 442 +++ .../h5p/assets/js/h5p-display-options.js | 54 + src/core/features/h5p/assets/js/h5p-embed.js | 75 + .../h5p/assets/js/h5p-event-dispatcher.js | 258 ++ .../h5p/assets/js/h5p-library-details.js | 297 ++ .../h5p/assets/js/h5p-library-list.js | 140 + .../features/h5p/assets/js/h5p-resizer.js | 131 + src/core/features/h5p/assets/js/h5p-utils.js | 506 +++ .../features/h5p/assets/js/h5p-version.js | 40 + .../features/h5p/assets/js/h5p-x-api-event.js | 331 ++ src/core/features/h5p/assets/js/h5p-x-api.js | 119 + src/core/features/h5p/assets/js/h5p.js | 2847 +++++++++++++++++ src/core/features/h5p/assets/js/jquery.js | 13 + .../features/h5p/assets/js/request-queue.js | 436 +++ .../h5p/assets/js/settings/h5p-disable-hub.js | 68 + .../features/h5p/assets/moodle/js/embed.js | 203 ++ .../h5p/assets/moodle/js/h5p_overrides.js | 55 + .../features/h5p/assets/moodle/js/params.js | 46 + .../features/h5p/assets/styles/h5p-admin.css | 358 +++ .../assets/styles/h5p-confirmation-dialog.css | 183 ++ .../h5p/assets/styles/h5p-core-button.css | 60 + src/core/features/h5p/assets/styles/h5p.css | 566 ++++ src/core/features/h5p/lang.json | 93 + 33 files changed, 8755 insertions(+) create mode 100644 src/core/features/h5p/assets/fonts/h5p-core-23.eot create mode 100644 src/core/features/h5p/assets/fonts/h5p-core-23.svg create mode 100644 src/core/features/h5p/assets/fonts/h5p-core-23.ttf create mode 100644 src/core/features/h5p/assets/fonts/h5p-core-23.woff create mode 100644 src/core/features/h5p/assets/js/h5p-action-bar.js create mode 100644 src/core/features/h5p/assets/js/h5p-confirmation-dialog.js create mode 100644 src/core/features/h5p/assets/js/h5p-content-type.js create mode 100644 src/core/features/h5p/assets/js/h5p-content-upgrade-process.js create mode 100644 src/core/features/h5p/assets/js/h5p-content-upgrade-worker.js create mode 100644 src/core/features/h5p/assets/js/h5p-content-upgrade.js create mode 100644 src/core/features/h5p/assets/js/h5p-data-view.js create mode 100644 src/core/features/h5p/assets/js/h5p-display-options.js create mode 100644 src/core/features/h5p/assets/js/h5p-embed.js create mode 100644 src/core/features/h5p/assets/js/h5p-event-dispatcher.js create mode 100644 src/core/features/h5p/assets/js/h5p-library-details.js create mode 100644 src/core/features/h5p/assets/js/h5p-library-list.js create mode 100644 src/core/features/h5p/assets/js/h5p-resizer.js create mode 100644 src/core/features/h5p/assets/js/h5p-utils.js create mode 100644 src/core/features/h5p/assets/js/h5p-version.js create mode 100644 src/core/features/h5p/assets/js/h5p-x-api-event.js create mode 100644 src/core/features/h5p/assets/js/h5p-x-api.js create mode 100644 src/core/features/h5p/assets/js/h5p.js create mode 100644 src/core/features/h5p/assets/js/jquery.js create mode 100644 src/core/features/h5p/assets/js/request-queue.js create mode 100644 src/core/features/h5p/assets/js/settings/h5p-disable-hub.js create mode 100644 src/core/features/h5p/assets/moodle/js/embed.js create mode 100644 src/core/features/h5p/assets/moodle/js/h5p_overrides.js create mode 100644 src/core/features/h5p/assets/moodle/js/params.js create mode 100644 src/core/features/h5p/assets/styles/h5p-admin.css create mode 100644 src/core/features/h5p/assets/styles/h5p-confirmation-dialog.css create mode 100644 src/core/features/h5p/assets/styles/h5p-core-button.css create mode 100644 src/core/features/h5p/assets/styles/h5p.css create mode 100644 src/core/features/h5p/lang.json diff --git a/src/core/features/h5p/assets/fonts/h5p-core-23.eot b/src/core/features/h5p/assets/fonts/h5p-core-23.eot new file mode 100644 index 0000000000000000000000000000000000000000..f86828cffdda12bed59b2ad866f8df857a15fca1 GIT binary patch literal 9224 zcmcgyd2n0DdEb4z?*i}uJX{aNK>~Orz?%d>0Hj2bl*B`}J7HrExN8+SpNM;!f^Kd`)VPJ89A;g8JL{ zK#6kYZ^N!P#)!dpH-DToH>124^?s(g%o4 zi1Z2RJHUL@&FO2TOm#uG?4cO;rOy~4uY}m?^QXxeLqg@!W(c~Bcsad^9#g1ubLLQ` z2*=ilMGpe60GIjY4Zr;8ujtG7ynOG=54{pPfA0Kw!2tL@zr6of$A-~w>-Egmx11v_53&9L$>8nzA z{Z)Fr{wn&Q^p5ktk&a1@(8?x?RPjo_`u{j}gdD@ro)K%TQ?;NLBn)Ku%$Z^O%y}nA zdqQpm%@cx#S~{&+(wX7&L{!r!00?^U^3h}Gk4kq)XE9e55*Hw0Vv!_iAvP(HZXu|& z9JOmm>U+p5D{0iO&|_0m|L|@<>Fm_h)HKr7ja+pyrrsN#iz!?3p7=K$qwP=C< z^O3FJqN}$4*OBRO(~+%$<_~1ERF`%_p$N^XeyS=~#A>Ei%&OSA{3sovR)4H$#gr}@ zt=2|pty;(zdMREK8SL+$3P+@1U@%|k+2LXQ1ox@(303jzsCIQ$J-Sb~;%hrPd!(Jw z=oO`6t*@(dJZ|Yf@F`EJl*vyiz90BnTBZle)t-F576@V!g=At(j7u{lO|n?OrEx{w zag|HXC@RGPcNk(#!*-=yL5h}DiB$XgC!>)_bh5v%x*YD@w(ZU@mCGmkWN9WEnd{?1So2P9`yq_1MLVSix$on#a8Hr>M?f4676?)kGSkMGUKe ztSy(X3{=adSTv&8MaCDC9*;j}wdK3J{w!ZeCUuWtS#2vGSh2`8p3^9=|NGjVJJ+t+ zxpU2j7V=%)R~JB}>4CVFZ|g=WpX}^hM(6xTM5G9wWQh5ortYLDp>S-U>27 zHj`_}wd8uNST%2Blu;`ZE7nwuZqoiHEmK*0W0(!JP$7Ok*tEQ40l{MMoe5}15;uNAX&uROp43r>yN_wIF+%f46`WouM z8a(!n2_~|}SJ>?iM)f*O+`j5BIepE^H0P4ZqaBUDTr$zuXb1gp-Jt(juh72~vh^T& zlszkXNe5PJ7gnm5La=RhgqBxOY#Kx#`{w9DUM*M3B_Z>bfqbC|To(mmiKHm!6tz&Z zp{%wE%4iu{;r?pPU{ysc?Eh!YuJpa9yqcwHrk3b4gQliyO(ysABWrV|plJq-U0NiV zE0|%OtD0Va-LUjsJ-dz^IB?`%Bd%#d&NX=#kDJkSI!a$L0&53l1?@VUf;ltTq;skr z4;W@gQ*)p}$l_aRn_}ZB?-%D&8?g z#Tp9R;VdAU`j=Lu7i;KRkC>HHqD2T^wdSstsDQGdne!994AY{skf?(3ajS}0&}I1I zvL>LZK=;Z}z7&n3+kORa8~vdFT>j!5zx2OJ6Sy1LgPo1;~ITu?G6oy(;s#cNR z6>5v=Azkz7N^!2JgzS*n88Uoa*7TO~7Twah)-uwf#7Z%l-gxfQM$*tF*@#2Lb;z?G zlp(QPmjTPRKbDXAu~#aU!OtidTzV+&Z`0&Zt0_Y-xvDq$wS>VT9VQ2RE(LT&bLiV&iNTUU!a58XT)$WTtW}C7`w|j2nsmjU1g{M$ zmnlg6!R@kNqgTo_6qXf<_ssN*;c1%gH+V$!FDXXU-~s-9Syv5=3FKnjKEnt(Gtn_6 z15*w)o9K)OoaxAZRsL?Xp%`M3FiOri{c1lGKB+6TBrP`{h_lr*f=?%88J!h~-rMx>pYcbVbLV8uH&%7u?P%Z4rWoj@&ho0;#Dod^of_Kwj@&P@0)}-iA?qPRVsb` zQvf5Aef_ojWH~Db^o<*NP|wQp){UI!pm|p|${RPr%S7R2(5P=Jif5`14EhCJe+q;- z&e7IsvvCs-=qOKL;352=hkTyQutV@aKAgsK@PtEToNOZRb8UQhxzVm&u-cb5+F{`k zpb+oTA%vsZN>)bt4z^@JOt(BYJ_0iwf<2Y6iLmhdWqQHFuNRJh!#2-NIF0~R?e#2| zyef=1?P=KX(e{J%n)+YSzWRStmHvxUJ;$ZF(IrQ5rQ4aX-H=LQyX(($YO12Ii&X77 zS@;I@*1km6PD5|J@!TU=Gl`rf53!QefphH;e0DQU(}+@prCCJ>;QOjEQI0yUuT6L~ zx1tE$Z&c)6Li#LJ0scjK3Hf8P{1|PP@H&8s5t*JsT?ybRtkEd)-LSo{kq@$rH0Aj2Rpd$`r;6)}u}xVEj-v~hG%dqc z2B>4ua2A85661BN;FhzcD#i+DE-ycE&^cYvKOsltPjIT&zxrZ8pVR{%+yIrn3Q6O# z&~5?V&i%SBg}9;Vch>*f2nG$2=ofYUYnt{oUHAQk9ti07-Kp|jpjLc%?UYqryI-`x zOIdVV7$W?jG*0$PZ%S)$79mid##!NlR9-l1G|nOgY5#Fe8&MM)yi=1lqKO$!TN;KJ45+*Yl* zKF0Ct7^OhNDNwAYknxgpkSRxE(F<<46oJbYUfW%k0vKT8&JRBIA}5DgrO^u*xGmro;a1&45o8fR z6st}z4F29lId4Jdwn2(iBfRVwl)DN&|-KSM&fPL?(F>p5~!cVNC+f zL>)D|65X?9B_M-)YGtxElXGhlWZG!>IB%{d(X~3y1R7_iaO&BRW zW2)L@SN)1bD198wji{!@f{I5m5=OMeW3BC?j0T$hO$EQ!6k;0l zr~KiRNAW6pR9r$h@#z>N(wL)j5fPw?R}K~o)Q5Rb7#=m!dgpC2n_~*A|L7Y`rfjBpJD5w14t!)-0VCC? z?o-tT)!{yTx8n7A#2QI7k60`%O_KyJv0#Tvj-ELNNW_Uw#5f={L3ZsTR0z&SgpQ+> z-aY)Wu}oh$sVIp|OF!nlr>i?{+re^%`aGV~imoWCs$dSMrRmnza9;)oiap(3=|+Q9 z4~f>lR}#u1#_Cgk0ZPVh7UNRL)7ZN!q#xrZCrd`i1~MaVcASGTc1Y}C#l)V3U8w== z$V${~Ye0~ihAWCX^c7plSK1(34XF|~;T=lS^S>)jP8NSxnwTgJW#hlIvRMnSbFXFN zzl~={CQ82*HQ{hPOTYNI-!%P?`$G}mWgw|IS*-s=@S@q`WPN`;n~l?SJd=snfAP08 zEBJ=PqFNA*NZEvK`UO9~xhv!Xp`A+r)`gR!WSAY5#)w8tQbx>7usN35OI4O-i0w^y zk}%8^;=5jI!?Lmdn4o|Vi=anDI@Ka$sDq6|HAsbBc7mQT2ue7)f?h;Ig3a0Sd)Age zG*pnT?y7!xVCuwpG{3DUzrnUQ5g6@tb6@M`3 zXU(ztN;MreRF-UNf`2k1DV2V!zAqSJk#IP|{6XDd^izO*hA#UI7Kzk%;-HNsf^R*F zyF;-?1GrZpRJIF#VcUiEx3KN~IbmpUUr?#Jp&f!aSOEw|v8^ma<3Xdw6d@foO9a+X zbc@|ozni}p3T1Hgu`w<&ZhBO&CluVLN}S%z=}jUGc#Qw~%*<{4H1jh2PILPC z#qn?{t=mE9=Sj4p5Oa1?33-gLk{)VmW}3pJn{Ib3!I{=lsde8N_1pA?Z8&NZW8E-a+#$F$u3Z)JmZJu)fm~)|Ygkc-1_!dJ zN$*)-oGj&iM%KEEO098P%jkMfOLM30X>qJ)aZ*}Wyfqk)2hDiwxlH``R@U(KWFO7; z>W1|7iBkQiT_fHJ)iP-&o@G|+d{ZvhGzqjYt~6b`!WTE)*7fa<;mr6GYPP2*t0w&Q zpNjd4nCU3%!u@pg}X47LE~}XRTH;cj&fA&hFVBJ={U4@q*~i- z#n*?-RUI9@072jN~j`VhP42MEptyAAzU!Un}MUy_KrZ&>o z(Y`8Vit%wWcK$3o&8DHrEzsam#C2uU4!?sYvUkv7hMQvg!puz1j?G(cwej4#W%G{Z z@Rh6A%%iwLEnBYHv1KzD{o%z_>bliv zvS#&cI+aS#u3m%YtFKF?=&xaP2)+$;VIF7MQG6p1ceZ_Gn5-kySaDnwFNJUnLWI+k zb9;!R)dg5~4o_}T+@LMd*x6-;%7!A3AKG0Twwyh$Xv<=*UfrfQ#9ID@+ z9Sl!tVGljnR#D_c%ekNXyk4K-@%nBNfJn4c&^hc54sEP^98L@3U4Pu96H8=}>x*mC zN!)vYw_6wMJ3k#|Fh2;FgWoLTZJIyMcoS#oF zalpQFa*7PG-7MwY!%X1Diiqzb&cRX~jl>bjy-HV2NPm{$bOCt~cN(>_*xSB~!Him4 zCgLJo$4~6?GWl7F&r~(~$l&J9gR8l(w2M^S{yT&UIEj2}XmfKy=9ToNcTu_DU_;*YmHv?H~+=>C!mi+}XRs(*ip_d;@U5yk@Nw`n%b#MgX63;0^AJ6~fd zn;x&fUVnX@2J3H(QAWD1_>@a%(<4tbpZM9X!0>N~ z*YWh^hhIrujhaS&{-|S}@%PZ+rSZfA3xAJ?#tn4*UMYxZQZwe=raXl)zD}XFLA!z`2OHuv$)6jhCN% zuCZ2B>{|nt;02#=zzp~Y8ZZa^QUjI&FE(HW@E01e2k~_lX8?R!049bS7nblZBR_1w z4ERqrU=H}r1}p=oeEglDT5{;#G@_3%zh?;y+RjlMLM29zf#%RAy1$gU_%Taviw^?<<`Dak&bVg0}bS sJqHiHe{msG>@8mYA6wpaJnu@dWzXD^{j&!z{ZE>AZG~NelD*UOzoJKLo&W#< literal 0 HcmV?d00001 diff --git a/src/core/features/h5p/assets/fonts/h5p-core-23.svg b/src/core/features/h5p/assets/fonts/h5p-core-23.svg new file mode 100644 index 000000000..dd65f1809 --- /dev/null +++ b/src/core/features/h5p/assets/fonts/h5p-core-23.svg @@ -0,0 +1,62 @@ + + + + + + +{ + "fontFamily": "h5p-core-21", + "description": "Font generated by IcoMoon.", + "majorVersion": 1, + "minorVersion": 1, + "version": "Version 1.1", + "fontId": "h5p-core-21", + "psName": "h5p-core-21", + "subFamily": "Regular", + "fullName": "h5p-core-21" +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/features/h5p/assets/fonts/h5p-core-23.ttf b/src/core/features/h5p/assets/fonts/h5p-core-23.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3a3348adf7f30f859ea69837ae25932cbd9d4c89 GIT binary patch literal 9044 zcmcgyd5~O3dGCJR@0yu6GjFapJIC%EyEA*w&b=$Gc309KvL#1aMe9h5C3!8cSIF93 zp#$GUSS|&hSY?9?qkyp!oWw3D5{L@|SmhGIP=o{+Y|=qrZMjC?Uj6juS=(w*GOU|cn+_jPYAKyqw%7gaWepILru~$$Y zN4b6f;?WyFPFh=0-jDJl2bcHFoltJsNl02ldHdqrjfY5;22j2V<V=`L37mdHKOt0vFC- zxL{LAy*U5keZL?te+2dSy+Z7Idg14>PseVIY1Zx5hpppQ7&KOoZFg+GD50R_Th%TR zQXjI>FZFq^x>v~|7NI8z@PcXjs?^(fm7Z+8iZLj??ZU656A~3|vWX&9+>)p9-*y`z zCor|A1&?*A=GDA}i431TJ4~OxVCU#h$o0T^T;Nbkr*%s@JA8qNX8Je)fe%_9dg8)y z={D&c)~Z6{d?ZLL5+`jWOY)?b6w$jJ&eoCC-y^TCrQvLqo|u~YyZ8D@=ccBnW{|FI z<^rDCgh#NLlJjH>WAw*j{vCPMqIvp{$98;+uG{gS$7a4wM|KFD-;!ymA?+d14VqEC zR8_2y)k>|1Rn2B{qjZE?1CgQ?QF>^&Rv)4DT0WQWr+7(ZaA0667?S+H!Cby?w~O(+ zxkr`nRu$LoT2FV)rF(QMy0NRfPudd>UsfvC3q9TAQA_`UM|o1EOny@F{J_)JHd86r z`f|Ct&yS4~Qi(Y+F3pl8NrQhY^9nojDwph8RErg78iJ?6Y_(iP3YS%h)CvQW;ZP_% zIZ&vrhP!v}y!}h%^4$elnhl4h3IlIxt@altBVnmB(RjN2SowtlRF85piFs_sE>;Bx zYxP&Bm=+J6ynDN?G7G)Rk>RvuYLCReT0%B4s+q-2YXFUMUjZ^Y`z_4fQpE+3EU zF2k}q*4)2lnQJ_wQQr91jeGWN+^}cQh7T>}dU~(S14+|;Q7hNci&8G$-M!3tS~lb> z_H1xaiHkf+_OfHrcH)7oWwCo}$OM@tSCOm9wcuDSm&Gi@Rwz=etC-&?tyKzCl_Rxk zy^_nS8Kr2cN~n#BraV(3Wvf)H<}10Pn$Kl?R5aB)KtT;f%JovMn6C>0qRC=r+GJ@n zO+UaDz96d}dH?27u5(M4M?2{+W$MxRBA5C{Ik!+f+L__9yvS8k$&AXHY;iu;*$8k7 zQCZ})b5ur^KzmYpzVrME={EWr+CUmK_Kyi7vc}hBJM9|PZ4+_EYP@8RwII`sgC>u5 zHOF$$#8{(U^urB<{zs!q|6It{1LP6*jN~R=;MiVps-Hrzv+4*fuc6p9h(7ks)`Of{ zu9ize=Bt%lzKFUWii#DKqMT9Gd_4arm!TETuigq$HT1&%f7a|t-gDZmS(;{Q zv4ZJ0HC<~lxtAZ?m?`;9(`W3}LjFwN4C-9f^v3IkrSI+Ad+gAmWA_+QP4jcE$$NR! z3@4Le`ikM(I4CRV*WKdJnEn=>Q|+YBFuPh>yFJUYx`q>@Q2~sBq$xGA+P9$g%>Yq`v-84Ipt70hw8BwD+`d%wT z`N5`AqzLmcP%5Ihh8C@kn&60vcSKRaLt#5?2BNEXg(Lmop<_KFRz?XIA$YaA!!6MO zWkEOlCq@~jMVpamg7I;hieTuf`r@iCz^Ov_%22)p4P)2=1@A1z!T6cnr8R)`5M|Ct z1EsRS%!rY?X%TWRwjNcOYF(*WMRrG^Bcca%&7&*Dg`yJ32F&h&;o-8Tw~e>ymd>@d zkv1h#ipcc(^Pe{2hAzoQ6e6xeo^`(riRHQsSk8JQxri5grBWI6jJ(062b10oO%Al1 zGW3$GdW%$g!C3`h`xl98=S&?|(?0}e_rs-aThs5}j zVuTIuA((_&a>gk~XT8PH zne5WJ)Hd3VdQHMCuqc^E44G!H5Osnjz>insx*v9ew*_Rc97*U@j zx5CSW;bqXNFcro#RRDnj0XLomVuo|{wfk({#(g@ldx|I~(nwa0pO{-_s!k!|7^TM*0r6yZ9)VPRqJn0(e?hUnl;mqaLCl0!xEBYto zko*Zw^~P6U@adDf?}J;Q(pMm9To&3bz+1Uj*QEeAH2wC*UmAYDArk$fu76F_zNYJ* zKhu3a{odPEz8Bbv2d_P{s%!U&9(XCsP7gzbACyMPe(6nV1I{7@>eDOoMl7(MB|pBWn?POhuW_0MOPs4eJ1&JPGWJh%ps={e=rZWoxA+c=Ab% z>i8i=*m3cL6LLIPKNMWJ8l2l|b;rlpULCU()o=&oFdO6hRF!moXHN014+}LuJV>xOdT>_vJW!lP$YcO4VOZ2*}`i(Y{`QF7Hn3>YcqkuavJVGnr{2k2}C5O8vf6he}6p9hEf2l?)js zc8*e?p?VeFhX|2Lu9&NJD3RX~LpRYz&7N5ATv_qSpq^M8ug_+jme_bVw#}FI^}-<~ z>amVNA7zr!;(;{a)D|cvtwIZC3eT9RwPb5v(Hiq$7LqKPm9}_20W?Ys&|#XyTQD1%tH^ zW^Aq4+qJX=qbK4KMnb-a!rn=5_#t0t6-bkBudy4&7&19aGAt)Og{Za{QP3or$H3uG z1XOM7Bdk#e_OypuWxE=|LDqDvQ`JPgq*gJq5IiQ%!@{10t%x{FR%sQ06PT=o93mRA zsSMPr^myEV+#j=o$Nh17>N;22xHq!PH12K8beje}gA=aLb&9#JV~pix##A;tt8m8T z#+}1px1lIUR5w4un4x&suv>|l^fFMgLW1T)*Xy3{I}Nk(1l`nl!Z1PbxRKPmZ;{yo zQ&{6i-(WIjv#q;8Tw=8HkzoadRF8TCt?)jeHWH#$Br>Mu}I-}@{ zqN)nka7LPGZx0qyI8e;@_9UAf);%bC|Bn(=mN8e4@^fG^cB7b=LY~6jT_*h)H#uoC zLbi}uakFC|jIl#v2dgIbBHV*(oj138!Mf*@H+omI{NEqdSs&XYta%6M$`0*k9keg`}3IM)va-0ma zh)K$bnF%(>GW)5@(hRY^2~QG)nL>QmPqVOWY#<^qAjBf*5s^-<$QasS$20` zsdbUax>WZc<<^zPa;d2tg5}hjme^3r<#S~xQvQz6So`KYAj3#aWb>Qb*G96z&nDw} z8q2y;jc4g_Qp+`dmXF2gzDQj0`u$$k8fmOmlR-me@s<|&CnJ9-mO`~emU21Cs2 z*9}HL1;}IQvd3VdP-71c+F%iU>v7y23LaH(uRy4r&3lDy7uMgxwhv^4p}~DYweEy= z2;yJ`AQ;7tvJ8y}jv7&fbkr>oSVPe*c0=P%{(?W?S9GYW3G>FrxWu^WQr)hA|9~oS zdLySdh%n$${-?9Ex9~H}&G0+J>F1ZngQcXN^+P{Tp%;aivr|gIWdxPvP)jS*6eitp zt8EF+wwFrn2gWF0djM-;zi=P4#1VZ{*56#8~ghz zt6}NOvG^q#m*9Bp{NcAyS)sV2r9~?LUpfv=*r-!vOu8QXStR#^10q&-t~LfL#VQK8 z;eb2?5XlzhFaVM4z_^SXHo)(Qm82K30N^$b?{}ae+K`-40aP)vLoYLsrO)rgQOkZb zZi5`QT;sP#P^Zbai$X?029>`?DH!K%- z2o8>8S4F&KtAS%6SJ~J)IBMhIKo&OXU7L%OrJTn|TX#^YJxc2-UGHma?bcmww)HGd zN}Gx|`=e368I3%fivEw4HavalN7DVeA$@(K)c9%7h%=WaSOTlibj}*E(*9A;5KTgIjoMUI$3^ch78a#@)u54z5@1lw9 zU38e?rkFlIJKMK=di%{;JU4Hj-n|;WeEo*G1fIDK>n~pg-!MD-C2G(I&|!M}?yI(M zzbV_^p1o=N_N#Vpp9Z1dzK}>)aRL-ASsbZZ15>-QX3wqKIt#q_$qRXk3K~{1p zzJe^)tdP%;UbdHYL6>sSrb%ei0#+<$y-J@D%SJ5RpW-6lUXHCFFb;1i1)H79MMSFb9+4Q@993neG%#wc-sc) zhGnhddhEMecnOyI_fi0>ly!BQNJ#1YB4 zN>@!tf12TR0eKL28uhZ++rp(_My;7q)5{&=zdpR>i-^PxmC7)mDp2B88@BA*(XZjH&jnmdZ~ z#nAL1JvOw>oyug}+*UYLieW#erAbeh)wsK(+TCW-W05XTIFKzCvtIw+L>PoZp=2u} z1j^~Qp~hXAHGy$g$U{HaQH8%k9QAg7EdGCRWVJy2;n+jF63ffZFTS+=2WM8}`zy4U z;>*i07C67nuo))4<`bI7*IM2F8cW&Cc;ofP>*Lhlcw?NJ)EsZTA^pQd<8^9IG~Q^u zK0!_U1$o4$TtZtOe!TVWf7|OD{uOcCo}T>hD~T)7(#$U$x2-e2|HCIb5Bk~P(IL>{ zVdrd0PMh=YbXuH(=!WvEoY32l0Nd#V>dqtm)=5;HGaf~H0O@h3j(i^ZIi%OnCyM+F z$kR@Lau#_Gbx$E3Z>A?ucM9;cNTS~uQV-H+n&~uhk)E(;<{}}|iU@uUqQI-kljQ4E zqg&|1Qcd~_&+&WYoP4AFGbN$iqn=j3t6i-au;yS1d0t^~0ep zlO=MLq~O-(0nMY=9Ln?f44Wd?kel$n%%*k)Fa>V^mGg&>ynlHqRqQX4EA0+P$ouW4 r6i(;;r1+lmc~6Ax^9#og&K)M(L4JW8!zaEua`-)aVV9s}@AmvJS2tPu literal 0 HcmV?d00001 diff --git a/src/core/features/h5p/assets/fonts/h5p-core-23.woff b/src/core/features/h5p/assets/fonts/h5p-core-23.woff new file mode 100644 index 0000000000000000000000000000000000000000..ff03f42489c69ff862254a487b6de5afb4b28998 GIT binary patch literal 9120 zcmcgyd5~O3dGCJR@0yu6GjFapJIC%EyEA*w&b=$Gc309KvL#1aMe9h5C3!8@D`f4i z(1C9dmP^4WR@vafC}8XaC$S5P1mc1KR=Gql6d}PzRN)jypuks%T}}ujpizGP-mJ8C zjLjcZ-cG;%`s=U1zP_WsetZ4a$w@*9p6tWKZRa~swlSi#`q}!&g%TlD1MX^(d;z6@ z-{RaM;P#_@yGZ~1`t6m4xg!GiZzu~|k^Bn>Z@d<`|0JaK-6E|&{I#?D=jQ;YcA~x} zQe^)}H|_@#xcg9U7fF4Py|jPv=ncSq9_5Ec`Y35_J-ECNbm<)^Zx`u=a`Vo`xf{@L z`Y%yV*{MncOLL3!z&(S$|4yXC=e{|7X!*!d%=h?3ItGZb*XeykCPey}^evP>?Bw(n zQl>h}s~_rLT@vsZAuk2knG0vg7()VP^$Mm?Mi0%DK%IaScnhio;bcGZ2=N1>0GImN zwLkmt&*_VIzIgYG54;q(aQ?yt)Dz(6e|GQB$%`KX{@#~}ji(oW8vA7IhL~pEW_{2) zZiSm%?FMgpVPCpgMCWlyro+O|Prs>O4Z{uZpvhgw)QhMu! zUrHw=D(YktMXI@xed>apqdg(lf#xwmLoJ=w zE$Qs=1tO~HV*ms_czNiF3&*8frE{383aRyxAhAfCw2>^ylU`Cp>vA|-M^b-}ytl?WrDDC%(>)%w z^zV6;CsfMhClt^3JZ)_=m2#~wm#h2y*eD^H7!%{tEJ>0y)^BB8VP{|De!c+Jqnu1)9GkI=Rk4D#dM$@}<4;lTzX(<-QmTtINQ)R&9$7~wSFO~_rARoW zWQ&Y1$6YRO#OlcP_WVgMACK!U!?HTo+_z?#YdoV--uRb|d-iPHuxHPP_b=sodaulb zNYi~$E7#GBQZC-zz07%9HsmYzY;Z`4i#$U1vSZSA;(@GXv3qOC1eqpR!4|@b(ONEx zQHHHhq*zxmzEN7M6sRgkYSnrrmsK-L(NdLA8x>V~rbNnCsaDNbaz!jNzu>YSndy;pbc59ZVnOdx1`b|yOT1@Wc$2Mk4e$(_Bd$o{1lQ)ApS2exys$uDS z`}Q6?bm-XKMpV=MoNMx49yP&|Jes0Y<%S8Nlnpozpe!g)9@w3W`e-RKSui5?a~QSCkM$3)Q6Qw&?3}7 zqhz~jb|6>9R0c8vqd59rD?|CQOr=N>=3$^zL~#u*S{*g9A}ZbyMa3El+hH#tntE4O zq#tYOSdWO6QNl$CUajt|mZ*TTpqc#>y$sW$y^yGa@o}n(SkP7Y;;JT~sY3V4P`(5W zquT)m?=1R3|C!vSIe_&LWlm26rLv&Rh@QG>5ppiJ9w-d8uGFj|yFJhm(F3~X(Usyt zQ3+%NW_Q5wa9Pva#@lpD=UUrHn-VESWP08CPa1JUmt-Rf5!WHlx?hIGa$N>2XT6bJ z#EZRBsSJKb-r&*$NpFWH2ii>;ddXG2#jC{(4(TvC*mKFJD?C+?QMpV(;`Q&6y&An- zrh%ZWNW5=$KnzdQbg#ifqJK#-!Up&8@5;JrU`!wvk?IGMi#ySy>+gTb<)6V zuRZe+)=VPj$OEh-b>SR41fShXlQg6hVQJRU3VdHJBFbUg^>ql3=2R4+`^}1+Lr9;7 zD!{)eFCc$JmLH+5vixybQh(k|AAg0r_$Zf7%eq_wcv@EE5!uBL$xMFt0lW^OVnn8= zQC9+Z8f!F)d>?G@E98AFB~97BdmXu4$f@GEVr)~|g5&4`CP~Y%l|E`4G@Qj?sl<4l zD!AozsfMw_naitB9CSff^pD9Q`D2{wjW0j%(H(>|B9x4Mb|xls{4HUJ-4ZRFQ^p{UVCI!*X|W9@KTnY7KR8vD2;fbMd~ zs&O2&>HYCQAQnTCmP}uJ`|g^{qf9PMLUD5MGIU>9FkzDJa+PwqzED0GE5$Tb(REXI z<-sFCC3^nZV4MmTF~VpX@yidTtH~5OMG!N}8R42U*EyGABI(_IUS0(^n?0feHn?mbfYuDRfd^IGKLkB=t_;mWOsj#DV$Q8>htO@ zlpM=CK)XrNCQr7fnw4sv|yz0jEP!Hw&oSJF%L!| z$&y)Vi`Nq%qx4ZUH^Q10@hdLHh#BEFm$k8nGU{vfw&cB9OMq$2oA3q`F2$|rVUO?+ zB0e2sL>jYoE+hgp@yfu0!CDA2wpQ%zT3Uk96Y&TmA>V^x@1!^Upf9uvq{%m9>`Kvx zOwN)F%Slfns_jJ-G)d;saX1tKSKImsYt(@~?V(oLMk6@LnvQjq;BvAoQf%4TO3&Y0Y|V;IYAD9RDl&5tl}QKR$?Z-44kZx;Q8Qnx~KaN z!)!cGH#HtNOfWodB=zo_WwyW+*7(8Km`vGh>n<>t7_EG0SOFu|qaIMzCDrCWe23!p zxWpPsG>2F$Db0`==@WLSWb2u2fJB^VM~oGr39@4sp+ayrB6J+3^p4?=jHL>}xT3^T zZ3CG1d{1vOoAsAd)Z=oUQFKL7RRwc6Bh9q82MZ}2DCT>6lFbI|9uTd6qr{YDjMbz3 z6qJnNf{Nho=1J_`Wzr9ElanSRWDA)UH#_#h7&|0(uxesYf{xSxwq+%3b~GVKP16;H zZTf0f$yGZbTTQ7FHsNhb(zCxVPEHnoU7DCE4W*;Mw$f<}uk){@qrZx#MTk9q?k&s#uJakAL>k>Ews#mUCOXgVFG$!IDSZT$4FXjGW)5@(hRY^2~QG)nL>QmPqVOW zY#<^iAjBf*5s^-<$QbHi<4_G!VVCWoCkTQPPOhLAk&s|>Zv4)T<@XQer7L@CAFNE> zH6G6G?8|M*X1C<}cIL)P>$20`sdbUax>WZc<<^zPa;d2tg5}hjme^3r<#S~xQvQz6 zSo`KYAj3#aWb>Qb*G96zPbcGf8q2y;jc4d^Qp+`dl8?pczDQj0`u$$k8fmOmlR-me z@s<|&CnJ~HGgn46QTw>gGscu)me?XNuy@Au~MHui1|G@0*&HM~=GyKkQ`q}02U@56*{m{=- zXhk9B?35C489^mE)Y8f{g-O@nVq1c81}d7SvzjPym^C#@yBFdOHd z>rMAb&8VPgW-yUFWL#)0?s05o4u{0dai3$pZCi;%sfd-GtBrw5v5Eq2I3UjeM6yLW3_v71FfQYU4e(o1CFw;f0Jx3A`z<(# zIwYr809Ewt@XHKj>2o`A)UqFq+aQN6*Z6G_;57L*QR!eJ2zFQC&&Z+;95yaNDE$^- z061tz9Q*7(k|Tz62Y?zgM&7To2+C47P66v8>g1@_??vVjdsUnNmU|ClW?&~9M^ zEu5PiJ2_CQWAs^hAd|(qVY;|OaA+L6D&j3$4IBfx%Es2QqBacXqqLsV^}e>&Zr#;pThHR8w5fQLKN|I$(a1BY=x?mF;pt01obJ~R>8lf^#*ceO z+!Ly0(o{6ftoFs0Or~WLXklDwvUHgzYC5eOyKKXm^2XG3Utd~{c^f|#^A$1Ean^(T z>lRo;aU+&(3VC@&2O<{k!c+#0$9-2_+-}**QO!2hLi$nHk&Pp@`m`0@95B~)b@i8b zwoQJ0%a5Gw&6U!7`)gKox>g(M@9G*31bW)1zP`CJ+tZFF1-qs`Qt0Yj7cj;6I2pTe zj-6pM(Bw8~@F?QCvY8FOjV7|U(P4(0V*1?dY~Swb?Kfrd+_Zgq_iFg^^&93Ac;+^& zzkC&Z{p{=)s6p>XgX!(NuiC!-#%z0g_QvhouiCwR8jODXd?InpdNkRvelD3vBT{ks>139S!rjby}csRPA99-T>|JRm(LH`Sx0-tYGOL11n@vq&YxL9u}%RA z)R0|+mh}BA4Q-xia%!cMm0XIi5Q{l0P-itJ>YwJ+s-t=H_N(;K^;f|^z z$J);S#N&2*442z;qX0yr-Ga_$cX()P!)0??67R;NCY@LzgIr%)n{MLV1N^Rav3{?) z<13bGsaD;p=j(4d^+UgNngq)pWGf;B%IUVD#+{ipfpJ&JL*Lg?g}*`^_2%_h{Qp>y)dKN{ zTOQh#SYCF1@ulTI_+mA_yTW@ZzPt=$f%Dr8n_&cYlh8cA*6Q}xSjuL`8?QEA9jE@r zYva_U=6K^Z>F*~RuTpcO@mk~632NFe$Rk2!LR%hsto5#c-Rm3vC2`xHo_zmHi7Qdl z%r6|bt+V(a0sBLw)6V{m4uKXAJ7;sU;pVH;sc}l88OkqnLT^O^Y^M{zok#kulc+dn zJc4vT(qj&ed=~jRq*u@;iu`lP(@uMG7I_Z1Cy|af)8oLM0{je;Xg7w`gY>CpI*nYU z$L*21NQkr|f?tCu@M`h|`6|`u7W$A>lfJ}r{BAiX-yr`)Nho)#r`2z3S8Gq`gZejJ z1FpYuZ*rgU_&rBG|8Cr3yzD*f^ZW2$7xpT2;vWy}i-?P>_+$D1$*{CX3X z0pHYw6~JF^!Y=rvwfn1w`t~g!p6@Fa5g=E|A%yY!FoeUn?C2vUQk+~~I+|LTUz$HW zcXa;UscUXbUAAxeisj{{emJycvP6!O6x`Z8pn0^KLwO#bVN>K9awFcC+1#!GrJ(J< za{lm<_be}^iv2}$rQP5Nd5>L{!s)!96yI????|wHe&N``xx@HplzEKn7(VgMk;CuU M3cCa)d%Nd<0Xg+y%K!iX literal 0 HcmV?d00001 diff --git a/src/core/features/h5p/assets/js/h5p-action-bar.js b/src/core/features/h5p/assets/js/h5p-action-bar.js new file mode 100644 index 000000000..608a848b3 --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-action-bar.js @@ -0,0 +1,100 @@ +/** + * @class + * @augments H5P.EventDispatcher + * @param {Object} displayOptions + * @param {boolean} displayOptions.export Triggers the display of the 'Download' button + * @param {boolean} displayOptions.copyright Triggers the display of the 'Copyright' button + * @param {boolean} displayOptions.embed Triggers the display of the 'Embed' button + * @param {boolean} displayOptions.icon Triggers the display of the 'H5P icon' link + */ +H5P.ActionBar = (function ($, EventDispatcher) { + "use strict"; + + function ActionBar(displayOptions) { + EventDispatcher.call(this); + + /** @alias H5P.ActionBar# */ + var self = this; + + var hasActions = false; + + // Create action bar + var $actions = H5P.jQuery('
    '); + + /** + * Helper for creating action bar buttons. + * + * @private + * @param {string} type + * @param {string} customClass Instead of type class + */ + var addActionButton = function (type, customClass) { + /** + * Handles selection of action + */ + var handler = function () { + self.trigger(type); + }; + H5P.jQuery('
  • ', { + 'class': 'h5p-button h5p-noselect h5p-' + (customClass ? customClass : type), + role: 'button', + tabindex: 0, + title: H5P.t(type + 'Description'), + html: H5P.t(type), + on: { + click: handler, + keypress: function (e) { + if (e.which === 32) { + handler(); + e.preventDefault(); // (since return false will block other inputs) + } + } + }, + appendTo: $actions + }); + + hasActions = true; + }; + + // Register action bar buttons + if (displayOptions.export || displayOptions.copy) { + // Add export button + addActionButton('reuse', 'export'); + } + if (displayOptions.copyright) { + addActionButton('copyrights'); + } + if (displayOptions.embed) { + addActionButton('embed'); + } + if (displayOptions.icon) { + // Add about H5P button icon + H5P.jQuery('
  • ').appendTo($actions); + hasActions = true; + } + + /** + * Returns a reference to the dom element + * + * @return {H5P.jQuery} + */ + self.getDOMElement = function () { + return $actions; + }; + + /** + * Does the actionbar contain actions? + * + * @return {Boolean} + */ + self.hasActions = function () { + return hasActions; + }; + } + + ActionBar.prototype = Object.create(EventDispatcher.prototype); + ActionBar.prototype.constructor = ActionBar; + + return ActionBar; + +})(H5P.jQuery, H5P.EventDispatcher); diff --git a/src/core/features/h5p/assets/js/h5p-confirmation-dialog.js b/src/core/features/h5p/assets/js/h5p-confirmation-dialog.js new file mode 100644 index 000000000..cd3536e7a --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-confirmation-dialog.js @@ -0,0 +1,410 @@ +/*global H5P*/ +H5P.ConfirmationDialog = (function (EventDispatcher) { + "use strict"; + + /** + * Create a confirmation dialog + * + * @param [options] Options for confirmation dialog + * @param [options.instance] Instance that uses confirmation dialog + * @param [options.headerText] Header text + * @param [options.dialogText] Dialog text + * @param [options.cancelText] Cancel dialog button text + * @param [options.confirmText] Confirm dialog button text + * @param [options.hideCancel] Hide cancel button + * @param [options.hideExit] Hide exit button + * @param [options.skipRestoreFocus] Skip restoring focus when hiding the dialog + * @param [options.classes] Extra classes for popup + * @constructor + */ + function ConfirmationDialog(options) { + EventDispatcher.call(this); + var self = this; + + // Make sure confirmation dialogs have unique id + H5P.ConfirmationDialog.uniqueId += 1; + var uniqueId = H5P.ConfirmationDialog.uniqueId; + + // Default options + options = options || {}; + options.headerText = options.headerText || H5P.t('confirmDialogHeader'); + options.dialogText = options.dialogText || H5P.t('confirmDialogBody'); + options.cancelText = options.cancelText || H5P.t('cancelLabel'); + options.confirmText = options.confirmText || H5P.t('confirmLabel'); + + /** + * Handle confirming event + * @param {Event} e + */ + function dialogConfirmed(e) { + self.hide(); + self.trigger('confirmed'); + e.preventDefault(); + } + + /** + * Handle dialog canceled + * @param {Event} e + */ + function dialogCanceled(e) { + self.hide(); + self.trigger('canceled'); + e.preventDefault(); + } + + /** + * Flow focus to element + * @param {HTMLElement} element Next element to be focused + * @param {Event} e Original tab event + */ + function flowTo(element, e) { + element.focus(); + e.preventDefault(); + } + + // Offset of exit button + var exitButtonOffset = 2 * 16; + var shadowOffset = 8; + + // Determine if we are too large for our container and must resize + var resizeIFrame = false; + + // Create background + var popupBackground = document.createElement('div'); + popupBackground.classList + .add('h5p-confirmation-dialog-background', 'hidden', 'hiding'); + + // Create outer popup + var popup = document.createElement('div'); + popup.classList.add('h5p-confirmation-dialog-popup', 'hidden'); + if (options.classes) { + options.classes.forEach(function (popupClass) { + popup.classList.add(popupClass); + }); + } + + popup.setAttribute('role', 'dialog'); + popup.setAttribute('aria-labelledby', 'h5p-confirmation-dialog-dialog-text-' + uniqueId); + popupBackground.appendChild(popup); + popup.addEventListener('keydown', function (e) { + if (e.which === 27) {// Esc key + // Exit dialog + dialogCanceled(e); + } + }); + + // Popup header + var header = document.createElement('div'); + header.classList.add('h5p-confirmation-dialog-header'); + popup.appendChild(header); + + // Header text + var headerText = document.createElement('div'); + headerText.classList.add('h5p-confirmation-dialog-header-text'); + headerText.innerHTML = options.headerText; + header.appendChild(headerText); + + // Popup body + var body = document.createElement('div'); + body.classList.add('h5p-confirmation-dialog-body'); + popup.appendChild(body); + + // Popup text + var text = document.createElement('div'); + text.classList.add('h5p-confirmation-dialog-text'); + text.innerHTML = options.dialogText; + text.id = 'h5p-confirmation-dialog-dialog-text-' + uniqueId; + body.appendChild(text); + + // Popup buttons + var buttons = document.createElement('div'); + buttons.classList.add('h5p-confirmation-dialog-buttons'); + body.appendChild(buttons); + + // Cancel button + var cancelButton = document.createElement('button'); + cancelButton.classList.add('h5p-core-cancel-button'); + cancelButton.textContent = options.cancelText; + + // Confirm button + var confirmButton = document.createElement('button'); + confirmButton.classList.add('h5p-core-button'); + confirmButton.classList.add('h5p-confirmation-dialog-confirm-button'); + confirmButton.textContent = options.confirmText; + + // Exit button + var exitButton = document.createElement('button'); + exitButton.classList.add('h5p-confirmation-dialog-exit'); + exitButton.setAttribute('aria-hidden', 'true'); + exitButton.tabIndex = -1; + exitButton.title = options.cancelText; + + // Cancel handler + cancelButton.addEventListener('click', dialogCanceled); + cancelButton.addEventListener('keydown', function (e) { + if (e.which === 32) { // Space + dialogCanceled(e); + } + else if (e.which === 9 && e.shiftKey) { // Shift-tab + flowTo(confirmButton, e); + } + }); + + if (!options.hideCancel) { + buttons.appendChild(cancelButton); + } + else { + // Center buttons + buttons.classList.add('center'); + } + + // Confirm handler + confirmButton.addEventListener('click', dialogConfirmed); + confirmButton.addEventListener('keydown', function (e) { + if (e.which === 32) { // Space + dialogConfirmed(e); + } + else if (e.which === 9 && !e.shiftKey) { // Tab + const nextButton = !options.hideCancel ? cancelButton : confirmButton; + flowTo(nextButton, e); + } + }); + buttons.appendChild(confirmButton); + + // Exit handler + exitButton.addEventListener('click', dialogCanceled); + exitButton.addEventListener('keydown', function (e) { + if (e.which === 32) { // Space + dialogCanceled(e); + } + }); + if (!options.hideExit) { + popup.appendChild(exitButton); + } + + // Wrapper element + var wrapperElement; + + // Focus capturing + var focusPredator; + + // Maintains hidden state of elements + var wrapperSiblingsHidden = []; + var popupSiblingsHidden = []; + + // Element with focus before dialog + var previouslyFocused; + + /** + * Set parent of confirmation dialog + * @param {HTMLElement} wrapper + * @returns {H5P.ConfirmationDialog} + */ + this.appendTo = function (wrapper) { + wrapperElement = wrapper; + return this; + }; + + /** + * Capture the focus element, send it to confirmation button + * @param {Event} e Original focus event + */ + var captureFocus = function (e) { + if (!popupBackground.contains(e.target)) { + e.preventDefault(); + confirmButton.focus(); + } + }; + + /** + * Hide siblings of element from assistive technology + * + * @param {HTMLElement} element + * @returns {Array} The previous hidden state of all siblings + */ + var hideSiblings = function (element) { + var hiddenSiblings = []; + var siblings = element.parentNode.children; + var i; + for (i = 0; i < siblings.length; i += 1) { + // Preserve hidden state + hiddenSiblings[i] = siblings[i].getAttribute('aria-hidden') ? + true : false; + + if (siblings[i] !== element) { + siblings[i].setAttribute('aria-hidden', true); + } + } + return hiddenSiblings; + }; + + /** + * Restores assistive technology state of element's siblings + * + * @param {HTMLElement} element + * @param {Array} hiddenSiblings Hidden state of all siblings + */ + var restoreSiblings = function (element, hiddenSiblings) { + var siblings = element.parentNode.children; + var i; + for (i = 0; i < siblings.length; i += 1) { + if (siblings[i] !== element && !hiddenSiblings[i]) { + siblings[i].removeAttribute('aria-hidden'); + } + } + }; + + /** + * Start capturing focus of parent and send it to dialog + */ + var startCapturingFocus = function () { + focusPredator = wrapperElement.parentNode || wrapperElement; + focusPredator.addEventListener('focus', captureFocus, true); + }; + + /** + * Clean up event listener for capturing focus + */ + var stopCapturingFocus = function () { + focusPredator.removeAttribute('aria-hidden'); + focusPredator.removeEventListener('focus', captureFocus, true); + }; + + /** + * Hide siblings in underlay from assistive technologies + */ + var disableUnderlay = function () { + wrapperSiblingsHidden = hideSiblings(wrapperElement); + popupSiblingsHidden = hideSiblings(popupBackground); + }; + + /** + * Restore state of underlay for assistive technologies + */ + var restoreUnderlay = function () { + restoreSiblings(wrapperElement, wrapperSiblingsHidden); + restoreSiblings(popupBackground, popupSiblingsHidden); + }; + + /** + * Fit popup to container. Makes sure it doesn't overflow. + * @params {number} [offsetTop] Offset of popup + */ + var fitToContainer = function (offsetTop) { + var popupOffsetTop = parseInt(popup.style.top, 10); + if (offsetTop !== undefined) { + popupOffsetTop = offsetTop; + } + + if (!popupOffsetTop) { + popupOffsetTop = 0; + } + + // Overflows height + if (popupOffsetTop + popup.offsetHeight > wrapperElement.offsetHeight) { + popupOffsetTop = wrapperElement.offsetHeight - popup.offsetHeight - shadowOffset; + } + + if (popupOffsetTop - exitButtonOffset <= 0) { + popupOffsetTop = exitButtonOffset + shadowOffset; + + // We are too big and must resize + resizeIFrame = true; + } + popup.style.top = popupOffsetTop + 'px'; + }; + + /** + * Show confirmation dialog + * @params {number} offsetTop Offset top + * @returns {H5P.ConfirmationDialog} + */ + this.show = function (offsetTop) { + // Capture focused item + previouslyFocused = document.activeElement; + wrapperElement.appendChild(popupBackground); + startCapturingFocus(); + disableUnderlay(); + popupBackground.classList.remove('hidden'); + fitToContainer(offsetTop); + setTimeout(function () { + popup.classList.remove('hidden'); + popupBackground.classList.remove('hiding'); + + setTimeout(function () { + // Focus confirm button + confirmButton.focus(); + + // Resize iFrame if necessary + if (resizeIFrame && options.instance) { + var minHeight = parseInt(popup.offsetHeight, 10) + + exitButtonOffset + (2 * shadowOffset); + self.setViewPortMinimumHeight(minHeight); + options.instance.trigger('resize'); + resizeIFrame = false; + } + }, 100); + }, 0); + + return this; + }; + + /** + * Hide confirmation dialog + * @returns {H5P.ConfirmationDialog} + */ + this.hide = function () { + popupBackground.classList.add('hiding'); + popup.classList.add('hidden'); + + // Restore focus + stopCapturingFocus(); + if (!options.skipRestoreFocus) { + previouslyFocused.focus(); + } + restoreUnderlay(); + setTimeout(function () { + popupBackground.classList.add('hidden'); + wrapperElement.removeChild(popupBackground); + self.setViewPortMinimumHeight(null); + }, 100); + + return this; + }; + + /** + * Retrieve element + * + * @return {HTMLElement} + */ + this.getElement = function () { + return popup; + }; + + /** + * Get previously focused element + * @return {HTMLElement} + */ + this.getPreviouslyFocused = function () { + return previouslyFocused; + }; + + /** + * Sets the minimum height of the view port + * + * @param {number|null} minHeight + */ + this.setViewPortMinimumHeight = function (minHeight) { + var container = document.querySelector('.h5p-container') || document.body; + container.style.minHeight = (typeof minHeight === 'number') ? (minHeight + 'px') : minHeight; + }; + } + + ConfirmationDialog.prototype = Object.create(EventDispatcher.prototype); + ConfirmationDialog.prototype.constructor = ConfirmationDialog; + + return ConfirmationDialog; + +}(H5P.EventDispatcher)); + +H5P.ConfirmationDialog.uniqueId = -1; diff --git a/src/core/features/h5p/assets/js/h5p-content-type.js b/src/core/features/h5p/assets/js/h5p-content-type.js new file mode 100644 index 000000000..47c4d21bf --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-content-type.js @@ -0,0 +1,41 @@ +/** + * H5P.ContentType is a base class for all content types. Used by newRunnable() + * + * Functions here may be overridable by the libraries. In special cases, + * it is also possible to override H5P.ContentType on a global level. + * + * NOTE that this doesn't actually 'extend' the event dispatcher but instead + * it creates a single instance which all content types shares as their base + * prototype. (in some cases this may be the root of strange event behavior) + * + * @class + * @augments H5P.EventDispatcher + */ +H5P.ContentType = function (isRootLibrary) { + + function ContentType() {} + + // Inherit from EventDispatcher. + ContentType.prototype = new H5P.EventDispatcher(); + + /** + * Is library standalone or not? Not beeing standalone, means it is + * included in another library + * + * @return {Boolean} + */ + ContentType.prototype.isRoot = function () { + return isRootLibrary; + }; + + /** + * Returns the file path of a file in the current library + * @param {string} filePath The path to the file relative to the library folder + * @return {string} The full path to the file + */ + ContentType.prototype.getLibraryFilePath = function (filePath) { + return H5P.getLibraryPath(this.libraryInfo.versionedNameNoSpaces) + '/' + filePath; + }; + + return ContentType; +}; diff --git a/src/core/features/h5p/assets/js/h5p-content-upgrade-process.js b/src/core/features/h5p/assets/js/h5p-content-upgrade-process.js new file mode 100644 index 000000000..fbaa4f2bf --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-content-upgrade-process.js @@ -0,0 +1,313 @@ +/*jshint -W083 */ +var H5PUpgrades = H5PUpgrades || {}; + +H5P.ContentUpgradeProcess = (function (Version) { + + /** + * @class + * @namespace H5P + */ + function ContentUpgradeProcess(name, oldVersion, newVersion, params, id, loadLibrary, done) { + var self = this; + + // Make params possible to work with + try { + params = JSON.parse(params); + if (!(params instanceof Object)) { + throw true; + } + } + catch (event) { + return done({ + type: 'errorParamsBroken', + id: id + }); + } + + self.loadLibrary = loadLibrary; + self.upgrade(name, oldVersion, newVersion, params.params, params.metadata, function (err, upgradedParams, upgradedMetadata) { + if (err) { + err.id = id; + return done(err); + } + + done(null, JSON.stringify({params: upgradedParams, metadata: upgradedMetadata})); + }); + } + + /** + * Run content upgrade. + * + * @public + * @param {string} name + * @param {Version} oldVersion + * @param {Version} newVersion + * @param {Object} params + * @param {Object} metadata + * @param {Function} done + */ + ContentUpgradeProcess.prototype.upgrade = function (name, oldVersion, newVersion, params, metadata, done) { + var self = this; + + // Load library details and upgrade routines + self.loadLibrary(name, newVersion, function (err, library) { + if (err) { + return done(err); + } + if (library.semantics === null) { + return done({ + type: 'libraryMissing', + library: library.name + ' ' + library.version.major + '.' + library.version.minor + }); + } + + // Run upgrade routines on params + self.processParams(library, oldVersion, newVersion, params, metadata, function (err, params, metadata) { + if (err) { + return done(err); + } + + // Check if any of the sub-libraries need upgrading + asyncSerial(library.semantics, function (index, field, next) { + self.processField(field, params[field.name], function (err, upgradedParams) { + if (upgradedParams) { + params[field.name] = upgradedParams; + } + next(err); + }); + }, function (err) { + done(err, params, metadata); + }); + }); + }); + }; + + /** + * Run upgrade hooks on params. + * + * @public + * @param {Object} library + * @param {Version} oldVersion + * @param {Version} newVersion + * @param {Object} params + * @param {Function} next + */ + ContentUpgradeProcess.prototype.processParams = function (library, oldVersion, newVersion, params, metadata, next) { + if (H5PUpgrades[library.name] === undefined) { + if (library.upgradesScript) { + // Upgrades script should be loaded so the upgrades should be here. + return next({ + type: 'scriptMissing', + library: library.name + ' ' + newVersion + }); + } + + // No upgrades script. Move on + return next(null, params, metadata); + } + + // Run upgrade hooks. Start by going through major versions + asyncSerial(H5PUpgrades[library.name], function (major, minors, nextMajor) { + if (major < oldVersion.major || major > newVersion.major) { + // Older than the current version or newer than the selected + nextMajor(); + } + else { + // Go through the minor versions for this major version + asyncSerial(minors, function (minor, upgrade, nextMinor) { + minor =+ minor; + if (minor <= oldVersion.minor || minor > newVersion.minor) { + // Older than or equal to the current version or newer than the selected + nextMinor(); + } + else { + // We found an upgrade hook, run it + var unnecessaryWrapper = (upgrade.contentUpgrade !== undefined ? upgrade.contentUpgrade : upgrade); + + try { + unnecessaryWrapper(params, function (err, upgradedParams, upgradedExtras) { + params = upgradedParams; + if (upgradedExtras && upgradedExtras.metadata) { // Optional + metadata = upgradedExtras.metadata; + } + nextMinor(err); + }, {metadata: metadata}); + } + catch (err) { + if (console && console.error) { + console.error("Error", err.stack); + console.error("Error", err.name); + console.error("Error", err.message); + } + next(err); + } + } + }, nextMajor); + } + }, function (err) { + next(err, params, metadata); + }); + }; + + /** + * Process parameter fields to find and upgrade sub-libraries. + * + * @public + * @param {Object} field + * @param {Object} params + * @param {Function} done + */ + ContentUpgradeProcess.prototype.processField = function (field, params, done) { + var self = this; + + if (params === undefined) { + return done(); + } + + switch (field.type) { + case 'library': + if (params.library === undefined || params.params === undefined) { + return done(); + } + + // Look for available upgrades + var usedLib = params.library.split(' ', 2); + for (var i = 0; i < field.options.length; i++) { + var availableLib = (typeof field.options[i] === 'string') ? field.options[i].split(' ', 2) : field.options[i].name.split(' ', 2); + if (availableLib[0] === usedLib[0]) { + if (availableLib[1] === usedLib[1]) { + return done(); // Same version + } + + // We have different versions + var usedVer = new Version(usedLib[1]); + var availableVer = new Version(availableLib[1]); + if (usedVer.major > availableVer.major || (usedVer.major === availableVer.major && usedVer.minor >= availableVer.minor)) { + return done({ + type: 'errorTooHighVersion', + used: usedLib[0] + ' ' + usedVer, + supported: availableLib[0] + ' ' + availableVer + }); // Larger or same version that's available + } + + // A newer version is available, upgrade params + return self.upgrade(availableLib[0], usedVer, availableVer, params.params, params.metadata, function (err, upgradedParams, upgradedMetadata) { + if (!err) { + params.library = availableLib[0] + ' ' + availableVer.major + '.' + availableVer.minor; + params.params = upgradedParams; + if (upgradedMetadata) { + params.metadata = upgradedMetadata; + } + } + done(err, params); + }); + } + } + + // Content type was not supporte by the higher version + done({ + type: 'errorNotSupported', + used: usedLib[0] + ' ' + usedVer + }); + break; + + case 'group': + if (field.fields.length === 1 && field.isSubContent !== true) { + // Single field to process, wrapper will be skipped + self.processField(field.fields[0], params, function (err, upgradedParams) { + if (upgradedParams) { + params = upgradedParams; + } + done(err, params); + }); + } + else { + // Go through all fields in the group + asyncSerial(field.fields, function (index, subField, next) { + var paramsToProcess = params ? params[subField.name] : null; + self.processField(subField, paramsToProcess, function (err, upgradedParams) { + if (upgradedParams) { + params[subField.name] = upgradedParams; + } + next(err); + }); + + }, function (err) { + done(err, params); + }); + } + break; + + case 'list': + // Go trough all params in the list + asyncSerial(params, function (index, subParams, next) { + self.processField(field.field, subParams, function (err, upgradedParams) { + if (upgradedParams) { + params[index] = upgradedParams; + } + next(err); + }); + }, function (err) { + done(err, params); + }); + break; + + default: + done(); + } + }; + + /** + * Helps process each property on the given object asynchronously in serial order. + * + * @private + * @param {Object} obj + * @param {Function} process + * @param {Function} finished + */ + var asyncSerial = function (obj, process, finished) { + var id, isArray = obj instanceof Array; + + // Keep track of each property that belongs to this object. + if (!isArray) { + var ids = []; + for (id in obj) { + if (obj.hasOwnProperty(id)) { + ids.push(id); + } + } + } + + var i = -1; // Keeps track of the current property + + /** + * Private. Process the next property + */ + var next = function () { + id = isArray ? i : ids[i]; + process(id, obj[id], check); + }; + + /** + * Private. Check if we're done or have an error. + * + * @param {String} err + */ + var check = function (err) { + // We need to use a real async function in order for the stack to clear. + setTimeout(function () { + i++; + if (i === (isArray ? obj.length : ids.length) || (err !== undefined && err !== null)) { + finished(err); + } + else { + next(); + } + }, 0); + }; + + check(); // Start + }; + + return ContentUpgradeProcess; +})(H5P.Version); diff --git a/src/core/features/h5p/assets/js/h5p-content-upgrade-worker.js b/src/core/features/h5p/assets/js/h5p-content-upgrade-worker.js new file mode 100644 index 000000000..3507a358a --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-content-upgrade-worker.js @@ -0,0 +1,63 @@ +/* global importScripts */ +var H5P = H5P || {}; +importScripts('h5p-version.js', 'h5p-content-upgrade-process.js'); + +var libraryLoadedCallback; + +/** + * Register message handlers + */ +var messageHandlers = { + newJob: function (job) { + // Start new job + new H5P.ContentUpgradeProcess(job.name, new H5P.Version(job.oldVersion), new H5P.Version(job.newVersion), job.params, job.id, function loadLibrary(name, version, next) { + // TODO: Cache? + postMessage({ + action: 'loadLibrary', + name: name, + version: version.toString() + }); + libraryLoadedCallback = next; + }, function done(err, result) { + if (err) { + // Return error + postMessage({ + action: 'error', + id: job.id, + err: err.message ? err.message : err + }); + + return; + } + + // Return upgraded content + postMessage({ + action: 'done', + id: job.id, + params: result + }); + }); + }, + libraryLoaded: function (data) { + var library = data.library; + if (library.upgradesScript) { + try { + importScripts(library.upgradesScript); + } + catch (err) { + libraryLoadedCallback(err); + return; + } + } + libraryLoadedCallback(null, data.library); + } +}; + +/** + * Handle messages from our master + */ +onmessage = function (event) { + if (event.data.action !== undefined && messageHandlers[event.data.action]) { + messageHandlers[event.data.action].call(this, event.data); + } +}; diff --git a/src/core/features/h5p/assets/js/h5p-content-upgrade.js b/src/core/features/h5p/assets/js/h5p-content-upgrade.js new file mode 100644 index 000000000..9dc066c5c --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-content-upgrade.js @@ -0,0 +1,445 @@ +/* global H5PAdminIntegration H5PUtils */ + +(function ($, Version) { + var info, $log, $container, librariesCache = {}, scriptsCache = {}; + + // Initialize + $(document).ready(function () { + // Get library info + info = H5PAdminIntegration.libraryInfo; + + // Get and reset container + const $wrapper = $('#h5p-admin-container').html(''); + $log = $('
      ').appendTo($wrapper); + $container = $('

      ' + info.message + '

      ').appendTo($wrapper); + + // Make it possible to select version + var $version = $(getVersionSelect(info.versions)).appendTo($container); + + // Add "go" button + $(''); + H5PLibraryDetails.$next = $(''); + + H5PLibraryDetails.$previous.on('click', function () { + if (H5PLibraryDetails.$previous.hasClass('disabled')) { + return; + } + + H5PLibraryDetails.currentPage--; + H5PLibraryDetails.updatePager(); + H5PLibraryDetails.createContentTable(); + }); + + H5PLibraryDetails.$next.on('click', function () { + if (H5PLibraryDetails.$next.hasClass('disabled')) { + return; + } + + H5PLibraryDetails.currentPage++; + H5PLibraryDetails.updatePager(); + H5PLibraryDetails.createContentTable(); + }); + + // This is the Page x of y widget: + H5PLibraryDetails.$pagerInfo = $(''); + + H5PLibraryDetails.$pager = $('
      ').append(H5PLibraryDetails.$previous, H5PLibraryDetails.$pagerInfo, H5PLibraryDetails.$next); + H5PLibraryDetails.$content.append(H5PLibraryDetails.$pager); + + H5PLibraryDetails.$pagerInfo.on('click', function () { + var width = H5PLibraryDetails.$pagerInfo.innerWidth(); + H5PLibraryDetails.$pagerInfo.hide(); + + // User has updated the pageNumber + var pageNumerUpdated = function () { + var newPageNum = $gotoInput.val()-1; + var intRegex = /^\d+$/; + + $goto.remove(); + H5PLibraryDetails.$pagerInfo.css({display: 'inline-block'}); + + // Check if input value is valid, and that it has actually changed + if (!(intRegex.test(newPageNum) && newPageNum >= 0 && newPageNum < H5PLibraryDetails.getNumPages() && newPageNum != H5PLibraryDetails.currentPage)) { + return; + } + + H5PLibraryDetails.currentPage = newPageNum; + H5PLibraryDetails.updatePager(); + H5PLibraryDetails.createContentTable(); + }; + + // We create an input box where the user may type in the page number + // he wants to be displayed. + // Reson for doing this is when user has ten-thousands of elements in list, + // this is the easiest way of getting to a specified page + var $gotoInput = $('', { + type: 'number', + min : 1, + max: H5PLibraryDetails.getNumPages(), + on: { + // Listen to blur, and the enter-key: + 'blur': pageNumerUpdated, + 'keyup': function (event) { + if (event.keyCode === 13) { + pageNumerUpdated(); + } + } + } + }).css({width: width}); + var $goto = $('', { + 'class': 'h5p-pager-goto' + }).css({width: width}).append($gotoInput).insertAfter(H5PLibraryDetails.$pagerInfo); + + $gotoInput.focus(); + }); + + H5PLibraryDetails.updatePager(); + }; + + /** + * Calculates number of pages + */ + H5PLibraryDetails.getNumPages = function () { + return Math.ceil(H5PLibraryDetails.currentContent.length / H5PLibraryDetails.PAGER_SIZE); + }; + + /** + * Update the pager text, and enables/disables the next and previous buttons as needed + */ + H5PLibraryDetails.updatePager = function () { + H5PLibraryDetails.$pagerInfo.css({display: 'inline-block'}); + + if (H5PLibraryDetails.getNumPages() > 0) { + var message = H5PUtils.translateReplace(H5PLibraryDetails.library.translations.pageXOfY, { + '$x': (H5PLibraryDetails.currentPage+1), + '$y': H5PLibraryDetails.getNumPages() + }); + H5PLibraryDetails.$pagerInfo.html(message); + } + else { + H5PLibraryDetails.$pagerInfo.html(''); + } + + H5PLibraryDetails.$previous.toggleClass('disabled', H5PLibraryDetails.currentPage <= 0); + H5PLibraryDetails.$next.toggleClass('disabled', H5PLibraryDetails.currentContent.length < (H5PLibraryDetails.currentPage+1)*H5PLibraryDetails.PAGER_SIZE); + }; + + /** + * Creates the search element + */ + H5PLibraryDetails.createSearchElement = function () { + + H5PLibraryDetails.$search = $(''); + + var performSeach = function () { + var searchString = $('.h5p-content-search > input').val(); + + // If search string same as previous, just do nothing + if (H5PLibraryDetails.currentFilter === searchString) { + return; + } + + if (searchString.trim().length === 0) { + // If empty search, use the complete list + H5PLibraryDetails.currentContent = H5PLibraryDetails.library.content; + } + else if (H5PLibraryDetails.filterCache[searchString]) { + // If search is cached, no need to filter + H5PLibraryDetails.currentContent = H5PLibraryDetails.filterCache[searchString]; + } + else { + var listToFilter = H5PLibraryDetails.library.content; + + // Check if we can filter the already filtered results (for performance) + if (searchString.length > 1 && H5PLibraryDetails.currentFilter === searchString.substr(0, H5PLibraryDetails.currentFilter.length)) { + listToFilter = H5PLibraryDetails.currentContent; + } + H5PLibraryDetails.currentContent = $.grep(listToFilter, function (content) { + return content.title && content.title.match(new RegExp(searchString, 'i')); + }); + } + + H5PLibraryDetails.currentFilter = searchString; + // Cache the current result + H5PLibraryDetails.filterCache[searchString] = H5PLibraryDetails.currentContent; + H5PLibraryDetails.currentPage = 0; + H5PLibraryDetails.createContentTable(); + + // Display search results: + if (H5PLibraryDetails.$searchResults) { + H5PLibraryDetails.$searchResults.remove(); + } + if (searchString.trim().length > 0) { + H5PLibraryDetails.$searchResults = $('' + H5PLibraryDetails.currentContent.length + ' hits on ' + H5PLibraryDetails.currentFilter + ''); + H5PLibraryDetails.$search.append(H5PLibraryDetails.$searchResults); + } + H5PLibraryDetails.updatePager(); + }; + + var inputTimer; + $('input', H5PLibraryDetails.$search).on('change keypress paste input', function () { + // Here we start the filtering + // We wait at least 500 ms after last input to perform search + if (inputTimer) { + clearTimeout(inputTimer); + } + + inputTimer = setTimeout( function () { + performSeach(); + }, 500); + }); + + H5PLibraryDetails.$content.append(H5PLibraryDetails.$search); + }; + + /** + * Creates the page size selector + */ + H5PLibraryDetails.createPageSizeSelector = function () { + H5PLibraryDetails.$search.append('
      ' + H5PLibraryDetails.library.translations.pageSizeSelectorLabel + ':102050100200
      '); + + // Listen to clicks on the page size selector: + $('.h5p-admin-pager-size-selector > span', H5PLibraryDetails.$search).on('click', function () { + H5PLibraryDetails.PAGER_SIZE = $(this).data('page-size'); + $('.h5p-admin-pager-size-selector > span', H5PLibraryDetails.$search).removeClass('selected'); + $(this).addClass('selected'); + H5PLibraryDetails.currentPage = 0; + H5PLibraryDetails.createContentTable(); + H5PLibraryDetails.updatePager(); + }); + }; + + // Initialize me: + $(document).ready(function () { + if (!H5PLibraryDetails.initialized) { + H5PLibraryDetails.initialized = true; + H5PLibraryDetails.init(); + } + }); + +})(H5P.jQuery); diff --git a/src/core/features/h5p/assets/js/h5p-library-list.js b/src/core/features/h5p/assets/js/h5p-library-list.js new file mode 100644 index 000000000..344b73672 --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-library-list.js @@ -0,0 +1,140 @@ +/* global H5PAdminIntegration H5PUtils */ +var H5PLibraryList = H5PLibraryList || {}; + +(function ($) { + + /** + * Initializing + */ + H5PLibraryList.init = function () { + var $adminContainer = H5P.jQuery(H5PAdminIntegration.containerSelector).html(''); + + var libraryList = H5PAdminIntegration.libraryList; + if (libraryList.notCached) { + $adminContainer.append(H5PUtils.getRebuildCache(libraryList.notCached)); + } + + // Create library list + $adminContainer.append(H5PLibraryList.createLibraryList(H5PAdminIntegration.libraryList)); + }; + + /** + * Create the library list + * + * @param {object} libraries List of libraries and headers + */ + H5PLibraryList.createLibraryList = function (libraries) { + var t = H5PAdminIntegration.l10n; + if (libraries.listData === undefined || libraries.listData.length === 0) { + return $('
      ' + t.NA + '
      '); + } + + // Create table + var $table = H5PUtils.createTable(libraries.listHeaders); + $table.addClass('libraries'); + + // Add libraries + $.each (libraries.listData, function (index, library) { + var $libraryRow = H5PUtils.createTableRow([ + library.title, + '', + { + text: library.numContent, + class: 'h5p-admin-center' + }, + { + text: library.numContentDependencies, + class: 'h5p-admin-center' + }, + { + text: library.numLibraryDependencies, + class: 'h5p-admin-center' + }, + '
      ' + + '' + + (library.detailsUrl ? '' : '') + + (library.deleteUrl ? '' : '') + + '
      ' + ]); + + H5PLibraryList.addRestricted($('.h5p-admin-restricted', $libraryRow), library.restrictedUrl, library.restricted); + + var hasContent = !(library.numContent === '' || library.numContent === 0); + if (library.upgradeUrl === null) { + $('.h5p-admin-upgrade-library', $libraryRow).remove(); + } + else if (library.upgradeUrl === false || !hasContent) { + $('.h5p-admin-upgrade-library', $libraryRow).attr('disabled', true); + } + else { + $('.h5p-admin-upgrade-library', $libraryRow).attr('title', t.upgradeLibrary).click(function () { + window.location.href = library.upgradeUrl; + }); + } + + // Open details view when clicked + $('.h5p-admin-view-library', $libraryRow).on('click', function () { + window.location.href = library.detailsUrl; + }); + + var $deleteButton = $('.h5p-admin-delete-library', $libraryRow); + if (libraries.notCached !== undefined || + hasContent || + (library.numContentDependencies !== '' && + library.numContentDependencies !== 0) || + (library.numLibraryDependencies !== '' && + library.numLibraryDependencies !== 0)) { + // Disabled delete if content. + $deleteButton.attr('disabled', true); + } + else { + // Go to delete page om click. + $deleteButton.attr('title', t.deleteLibrary).on('click', function () { + window.location.href = library.deleteUrl; + }); + } + + $table.append($libraryRow); + }); + + return $table; + }; + + H5PLibraryList.addRestricted = function ($checkbox, url, selected) { + if (selected === null) { + $checkbox.remove(); + } + else { + $checkbox.change(function () { + $checkbox.attr('disabled', true); + + $.ajax({ + dataType: 'json', + url: url, + cache: false + }).fail(function () { + $checkbox.attr('disabled', false); + + // Reset + $checkbox.attr('checked', !$checkbox.is(':checked')); + }).done(function (result) { + url = result.url; + $checkbox.attr('disabled', false); + }); + }); + + if (selected) { + $checkbox.attr('checked', true); + } + } + }; + + // Initialize me: + $(document).ready(function () { + if (!H5PLibraryList.initialized) { + H5PLibraryList.initialized = true; + H5PLibraryList.init(); + } + }); + +})(H5P.jQuery); diff --git a/src/core/features/h5p/assets/js/h5p-resizer.js b/src/core/features/h5p/assets/js/h5p-resizer.js new file mode 100644 index 000000000..ed78724ec --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-resizer.js @@ -0,0 +1,131 @@ +// H5P iframe Resizer +(function () { + if (!window.postMessage || !window.addEventListener || window.h5pResizerInitialized) { + return; // Not supported + } + window.h5pResizerInitialized = true; + + // Map actions to handlers + var actionHandlers = {}; + + /** + * Prepare iframe resize. + * + * @private + * @param {Object} iframe Element + * @param {Object} data Payload + * @param {Function} respond Send a response to the iframe + */ + actionHandlers.hello = function (iframe, data, respond) { + // Make iframe responsive + iframe.style.width = '100%'; + + // Bugfix for Chrome: Force update of iframe width. If this is not done the + // document size may not be updated before the content resizes. + iframe.getBoundingClientRect(); + + // Tell iframe that it needs to resize when our window resizes + var resize = function () { + if (iframe.contentWindow) { + // Limit resize calls to avoid flickering + respond('resize'); + } + else { + // Frame is gone, unregister. + window.removeEventListener('resize', resize); + } + }; + window.addEventListener('resize', resize, false); + + // Respond to let the iframe know we can resize it + respond('hello'); + }; + + /** + * Prepare iframe resize. + * + * @private + * @param {Object} iframe Element + * @param {Object} data Payload + * @param {Function} respond Send a response to the iframe + */ + actionHandlers.prepareResize = function (iframe, data, respond) { + // Do not resize unless page and scrolling differs + if (iframe.clientHeight !== data.scrollHeight || + data.scrollHeight !== data.clientHeight) { + + // Reset iframe height, in case content has shrinked. + iframe.style.height = data.clientHeight + 'px'; + respond('resizePrepared'); + } + }; + + /** + * Resize parent and iframe to desired height. + * + * @private + * @param {Object} iframe Element + * @param {Object} data Payload + * @param {Function} respond Send a response to the iframe + */ + actionHandlers.resize = function (iframe, data) { + // Resize iframe so all content is visible. Use scrollHeight to make sure we get everything + iframe.style.height = data.scrollHeight + 'px'; + }; + + /** + * Keyup event handler. Exits full screen on escape. + * + * @param {Event} event + */ + var escape = function (event) { + if (event.keyCode === 27) { + exitFullScreen(); + } + }; + + // Listen for messages from iframes + window.addEventListener('message', function receiveMessage(event) { + if (event.data.context !== 'h5p') { + return; // Only handle h5p requests. + } + + // Find out who sent the message + var iframe, iframes = document.getElementsByTagName('iframe'); + for (var i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === event.source) { + iframe = iframes[i]; + break; + } + } + + if (!iframe) { + return; // Cannot find sender + } + + // Find action handler handler + if (actionHandlers[event.data.action]) { + actionHandlers[event.data.action](iframe, event.data, function respond(action, data) { + if (data === undefined) { + data = {}; + } + data.action = action; + data.context = 'h5p'; + event.source.postMessage(data, event.origin); + }); + } + }, false); + + // Let h5p iframes know we're ready! + var iframes = document.getElementsByTagName('iframe'); + var ready = { + context: 'h5p', + action: 'ready' + }; + for (var i = 0; i < iframes.length; i++) { + if (iframes[i].src.indexOf('h5p') !== -1) { + iframes[i].contentWindow.postMessage(ready, '*'); + } + } + +})(); diff --git a/src/core/features/h5p/assets/js/h5p-utils.js b/src/core/features/h5p/assets/js/h5p-utils.js new file mode 100644 index 000000000..b5aa3334e --- /dev/null +++ b/src/core/features/h5p/assets/js/h5p-utils.js @@ -0,0 +1,506 @@ +/* global H5PAdminIntegration*/ +var H5PUtils = H5PUtils || {}; + +(function ($) { + /** + * Generic function for creating a table including the headers + * + * @param {array} headers List of headers + */ + H5PUtils.createTable = function (headers) { + var $table = $('
      '); + + if (headers) { + var $thead = $(''); + var $tr = $(''); + + $.each(headers, function (index, value) { + if (!(value instanceof Object)) { + value = { + html: value + }; + } + + $('', value).appendTo($tr); + }); + + $table.append($thead.append($tr)); + } + + return $table; + }; + + /** + * Generic function for creating a table row + * + * @param {array} rows Value list. Object name is used as class name in + */ + H5PUtils.createTableRow = function (rows) { + var $tr = $(''); + + $.each(rows, function (index, value) { + if (!(value instanceof Object)) { + value = { + html: value + }; + } + + $('', value).appendTo($tr); + }); + + return $tr; + }; + + /** + * Generic function for creating a field containing label and value + * + * @param {string} label The label displayed in front of the value + * @param {string} value The value + */ + H5PUtils.createLabeledField = function (label, value) { + var $field = $('
      '); + + $field.append('
      ' + label + '
      '); + $field.append('
      ' + value + '
      '); + + return $field; + }; + + /** + * Replaces placeholder fields in translation strings + * + * @param {string} template The translation template string in the following format: "$name is a $sex" + * @param {array} replacors An js object with key and values. Eg: {'$name': 'Frode', '$sex': 'male'} + */ + H5PUtils.translateReplace = function (template, replacors) { + $.each(replacors, function (key, value) { + template = template.replace(new RegExp('\\'+key, 'g'), value); + }); + return template; + }; + + /** + * Get throbber with given text. + * + * @param {String} text + * @returns {$} + */ + H5PUtils.throbber = function (text) { + return $('
      ', { + class: 'h5p-throbber', + text: text + }); + }; + + /** + * Makes it possbile to rebuild all content caches from admin UI. + * @param {Object} notCached + * @returns {$} + */ + H5PUtils.getRebuildCache = function (notCached) { + var $container = $('

      ' + notCached.message + '

      ' + notCached.progress + '

      '); + var $button = $('').appendTo($container).click(function () { + var $spinner = $('
      ', {class: 'h5p-spinner'}).replaceAll($button); + var parts = ['|', '/', '-', '\\']; + var current = 0; + var spinning = setInterval(function () { + $spinner.text(parts[current]); + current++; + if (current === parts.length) current = 0; + }, 100); + + var $counter = $container.find('.progress'); + var build = function () { + $.post(notCached.url, function (left) { + if (left === '0') { + clearInterval(spinning); + $container.remove(); + location.reload(); + } + else { + var counter = $counter.text().split(' '); + counter[0] = left; + $counter.text(counter.join(' ')); + build(); + } + }); + }; + build(); + }); + + return $container; + }; + + /** + * Generic table class with useful helpers. + * + * @class + * @param {Object} classes + * Custom html classes to use on elements. + * e.g. {tableClass: 'fixed'}. + */ + H5PUtils.Table = function (classes) { + var numCols; + var sortByCol; + var $sortCol; + var sortCol; + var sortDir; + + // Create basic table + var tableOptions = {}; + if (classes.table !== undefined) { + tableOptions['class'] = classes.table; + } + var $table = $('', tableOptions); + var $thead = $('').appendTo($table); + var $tfoot = $('').appendTo($table); + var $tbody = $('').appendTo($table); + + /** + * Add columns to given table row. + * + * @private + * @param {jQuery} $tr Table row + * @param {(String|Object)} col Column properties + * @param {Number} id Used to seperate the columns + */ + var addCol = function ($tr, col, id) { + var options = { + on: {} + }; + + if (!(col instanceof Object)) { + options.text = col; + } + else { + if (col.text !== undefined) { + options.text = col.text; + } + if (col.class !== undefined) { + options.class = col.class; + } + + if (sortByCol !== undefined && col.sortable === true) { + // Make sortable + options.role = 'button'; + options.tabIndex = 0; + + // This is the first sortable column, use as default sort + if (sortCol === undefined) { + sortCol = id; + sortDir = 0; + } + + // This is the sort column + if (sortCol === id) { + options['class'] = 'h5p-sort'; + if (sortDir === 1) { + options['class'] += ' h5p-reverse'; + } + } + + options.on.click = function () { + sort($th, id); + }; + options.on.keypress = function (event) { + if ((event.charCode || event.keyCode) === 32) { // Space + sort($th, id); + } + }; + } + } + + // Append + var $th = $(''); + var $tr = $('').appendTo($newThead); + for (var i = 0; i < cols.length; i++) { + addCol($tr, cols[i], i); + } + + // Update DOM + $thead.replaceWith($newThead); + $thead = $newThead; + }; + + /** + * Set table rows. + * + * @public + * @param {Array} rows Table rows with cols: [[1,'hello',3],[2,'asd',6]] + */ + this.setRows = function (rows) { + var $newTbody = $(''); + + for (var i = 0; i < rows.length; i++) { + var $tr = $('').appendTo($newTbody); + + for (var j = 0; j < rows[i].length; j++) { + $(''); + var $tr = $('').appendTo($newTbody); + $(''); + var $tr = $('').appendTo($newTfoot); + $('"!==p[1]||ue.test(a)?0:u:u.firstChild)&&a.childNodes.length;o--;)d.nodeName(c=a.childNodes[o],"tbody")&&!c.childNodes.length&&a.removeChild(c);for(d.merge(m,u.childNodes),u.textContent="";u.firstChild;)u.removeChild(u.firstChild);u=g.lastChild}else m.push(t.createTextNode(a));for(u&&g.removeChild(u),f.appendChecked||d.grep(oe(m,"input"),le),v=0;a=m[v++];)if(r&&d.inArray(a,r)>-1)i&&i.push(a);else if(s=d.contains(a.ownerDocument,a),u=oe(g.appendChild(a),"script"),s&&ae(u),n)for(o=0;a=u[o++];)ee.test(a.type||"")&&n.push(a);return u=null,g}!function(){var t,n,i=r.createElement("div");for(t in{submit:!0,change:!0,focusin:!0})n="on"+t,(f[t]=n in e)||(i.setAttribute(n,"t"),f[t]=!1===i.attributes[n].expando);i=null}();var fe=/^(?:input|select|textarea)$/i,de=/^key/,pe=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,he=/^(?:focusinfocus|focusoutblur)$/,ge=/^([^.]*)(?:\.(.+)|)/;function me(){return!0}function ve(){return!1}function ye(){try{return r.activeElement}catch(e){}}function xe(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)xe(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=ve;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return d().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=d.guid++)),e.each(function(){d.event.add(this,t,i,r,n)})}d.event={global:{},add:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,h,g,m,v=d._data(e);if(v){for(n.handler&&(n=(u=n).handler,i=u.selector),n.guid||(n.guid=d.guid++),(a=v.events)||(a=v.events={}),(c=v.handle)||((c=v.handle=function(e){return void 0===d||e&&d.event.triggered===e.type?void 0:d.event.dispatch.apply(c.elem,arguments)}).elem=e),s=(t=(t||"").match(H)||[""]).length;s--;)h=m=(o=ge.exec(t[s])||[])[1],g=(o[2]||"").split(".").sort(),h&&(l=d.event.special[h]||{},h=(i?l.delegateType:l.bindType)||h,l=d.event.special[h]||{},f=d.extend({type:h,origType:m,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&d.expr.match.needsContext.test(i),namespace:g.join(".")},u),(p=a[h])||((p=a[h]=[]).delegateCount=0,l.setup&&!1!==l.setup.call(e,r,g,c)||(e.addEventListener?e.addEventListener(h,c,!1):e.attachEvent&&e.attachEvent("on"+h,c))),l.add&&(l.add.call(e,f),f.handler.guid||(f.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,f):p.push(f),d.event.global[h]=!0);e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,h,g,m,v=d.hasData(e)&&d._data(e);if(v&&(c=v.events)){for(l=(t=(t||"").match(H)||[""]).length;l--;)if(h=m=(s=ge.exec(t[l])||[])[1],g=(s[2]||"").split(".").sort(),h){for(f=d.event.special[h]||{},p=c[h=(r?f.delegateType:f.bindType)||h]||[],s=s[2]&&new RegExp("(^|\\.)"+g.join("\\.(?:.*\\.|)")+"(\\.|$)"),u=o=p.length;o--;)a=p[o],!i&&m!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(p.splice(o,1),a.selector&&p.delegateCount--,f.remove&&f.remove.call(e,a));u&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,g,v.handle)||d.removeEvent(e,h,v.handle),delete c[h])}else for(h in c)d.event.remove(e,h+t[l],n,r,!0);d.isEmptyObject(c)&&(delete v.handle,d._removeData(e,"events"))}},trigger:function(t,n,i,o){var a,s,u,l,f,p,h,g=[i||r],m=c.call(t,"type")?t.type:t,v=c.call(t,"namespace")?t.namespace.split("."):[];if(u=p=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!he.test(m+d.event.triggered)&&(m.indexOf(".")>-1&&(v=m.split("."),m=v.shift(),v.sort()),s=m.indexOf(":")<0&&"on"+m,(t=t[d.expando]?t:new d.Event(m,"object"==typeof t&&t)).isTrigger=o?2:3,t.namespace=v.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+v.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:d.makeArray(n,[t]),f=d.event.special[m]||{},o||!f.trigger||!1!==f.trigger.apply(i,n))){if(!o&&!f.noBubble&&!d.isWindow(i)){for(l=f.delegateType||m,he.test(l+m)||(u=u.parentNode);u;u=u.parentNode)g.push(u),p=u;p===(i.ownerDocument||r)&&g.push(p.defaultView||p.parentWindow||e)}for(h=0;(u=g[h++])&&!t.isPropagationStopped();)t.type=h>1?l:f.bindType||m,(a=(d._data(u,"events")||{})[t.type]&&d._data(u,"handle"))&&a.apply(u,n),(a=s&&u[s])&&a.apply&&M(u)&&(t.result=a.apply(u,n),!1===t.result&&t.preventDefault());if(t.type=m,!o&&!t.isDefaultPrevented()&&(!f._default||!1===f._default.apply(g.pop(),n))&&M(i)&&s&&i[m]&&!d.isWindow(i)){(p=i[s])&&(i[s]=null),d.event.triggered=m;try{i[m]()}catch(e){}d.event.triggered=void 0,p&&(i[s]=p)}return t.result}},dispatch:function(e){e=d.event.fix(e);var t,n,r,o,a,s,u=i.call(arguments),l=(d._data(this,"events")||{})[e.type]||[],c=d.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||!1!==c.preDispatch.call(this,e)){for(s=d.event.handlers.call(this,e,l),t=0;(o=s[t++])&&!e.isPropagationStopped();)for(e.currentTarget=o.elem,n=0;(a=o.handlers[n++])&&!e.isImmediatePropagationStopped();)e.rnamespace&&!e.rnamespace.test(a.namespace)||(e.handleObj=a,e.data=a.data,void 0!==(r=((d.event.special[a.origType]||{}).handle||a.handler).apply(o.elem,u))&&!1===(e.result=r)&&(e.preventDefault(),e.stopPropagation()));return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,t){var n,r,i,o,a=[],s=t.delegateCount,u=e.target;if(s&&u.nodeType&&("click"!==e.type||isNaN(e.button)||e.button<1))for(;u!=this;u=u.parentNode||this)if(1===u.nodeType&&(!0!==u.disabled||"click"!==e.type)){for(r=[],n=0;n-1:d.find(i,this,null,[u]).length),r[i]&&r.push(o);r.length&&a.push({elem:u,handlers:r})}return s]","i"),Te=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi,Ce=/\s*$/g,Se=re(r).appendChild(r.createElement("div"));function Ae(e,t){return d.nodeName(e,"table")&&d.nodeName(11!==t.nodeType?t:t.firstChild,"tr")?e.getElementsByTagName("tbody")[0]||e.appendChild(e.ownerDocument.createElement("tbody")):e}function De(e){return e.type=(null!==d.find.attr(e,"type"))+"/"+e.type,e}function je(e){var t=Ne.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function Le(e,t){if(1===t.nodeType&&d.hasData(e)){var n,r,i,o=d._data(e),a=d._data(t,o),s=o.events;if(s)for(n in delete a.handle,a.events={},s)for(r=0,i=s[n].length;r1&&"string"==typeof m&&!f.checkClone&&Ee.test(m))return e.each(function(i){var o=e.eq(i);v&&(t[0]=m.call(this,i,o.html())),qe(o,t,n,r)});if(h&&(i=(c=ce(t,e[0].ownerDocument,!1,e,r)).firstChild,1===c.childNodes.length&&(c=i),i||r)){for(s=(u=d.map(oe(c,"script"),De)).length;p")},clone:function(e,t,n){var r,i,o,a,s,u=d.contains(e.ownerDocument,e);if(f.html5Clone||d.isXMLDoc(e)||!we.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Se.innerHTML=e.outerHTML,Se.removeChild(o=Se.firstChild)),!(f.noCloneEvent&&f.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||d.isXMLDoc(e)))for(r=oe(o),s=oe(e),a=0;null!=(i=s[a]);++a)r[a]&&He(i,r[a]);if(t)if(n)for(s=s||oe(e),r=r||oe(o),a=0;null!=(i=s[a]);a++)Le(i,r[a]);else Le(e,o);return(r=oe(o,"script")).length>0&&ae(r,!u&&oe(e,"script")),r=s=i=null,o},cleanData:function(e,t){for(var r,i,o,a,s=0,u=d.expando,l=d.cache,c=f.attributes,p=d.event.special;null!=(r=e[s]);s++)if((t||M(r))&&(a=(o=r[u])&&l[o])){if(a.events)for(i in a.events)p[i]?d.event.remove(r,i):d.removeEvent(r,i,a.handle);l[o]&&(delete l[o],c||void 0===r.removeAttribute?r[u]=void 0:r.removeAttribute(u),n.push(o))}}}),d.fn.extend({domManip:qe,detach:function(e){return _e(this,e,!0)},remove:function(e){return _e(this,e)},text:function(e){return Q(this,function(e){return void 0===e?d.text(this):this.empty().append((this[0]&&this[0].ownerDocument||r).createTextNode(e))},null,e,arguments.length)},append:function(){return qe(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Ae(this,e).appendChild(e)})},prepend:function(){return qe(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Ae(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return qe(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return qe(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++){for(1===e.nodeType&&d.cleanData(oe(e,!1));e.firstChild;)e.removeChild(e.firstChild);e.options&&d.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return d.clone(this,e,t)})},html:function(e){return Q(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e)return 1===t.nodeType?t.innerHTML.replace(be,""):void 0;if("string"==typeof e&&!Ce.test(e)&&(f.htmlSerialize||!we.test(e))&&(f.leadingWhitespace||!te.test(e))&&!ie[(Z.exec(e)||["",""])[1].toLowerCase()]){e=d.htmlPrefilter(e);try{for(;n")).appendTo(t.documentElement))[0].contentWindow||Fe[0].contentDocument).document).write(),t.close(),n=Oe(e,t),Fe.detach()),Me[e]=n),n}var Pe=/^margin/,Be=new RegExp("^("+$+")(?!px)[a-z%]+$","i"),We=function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];for(o in i=n.apply(e,r||[]),t)e.style[o]=a[o];return i},Ie=r.documentElement;!function(){var t,n,i,o,a,s,u=r.createElement("div"),l=r.createElement("div");function c(){var c,f,d=r.documentElement;d.appendChild(u),l.style.cssText="-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;display:block;margin:auto;border:1px;padding:1px;top:1%;width:50%",t=i=s=!1,n=a=!0,e.getComputedStyle&&(f=e.getComputedStyle(l),t="1%"!==(f||{}).top,s="2px"===(f||{}).marginLeft,i="4px"===(f||{width:"4px"}).width,l.style.marginRight="50%",n="4px"===(f||{marginRight:"4px"}).marginRight,(c=l.appendChild(r.createElement("div"))).style.cssText=l.style.cssText="-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;display:block;margin:0;border:0;padding:0",c.style.marginRight=c.style.width="0",l.style.width="1px",a=!parseFloat((e.getComputedStyle(c)||{}).marginRight),l.removeChild(c)),l.style.display="none",(o=0===l.getClientRects().length)&&(l.style.display="",l.innerHTML="
      ', options).appendTo($tr); + if (sortCol === id) { + $sortCol = $th; // Default sort column + } + }; + + /** + * Updates the UI when a column header has been clicked. + * Triggers sorting callback. + * + * @private + * @param {jQuery} $th Table header + * @param {Number} id Used to seperate the columns + */ + var sort = function ($th, id) { + if (id === sortCol) { + // Change sorting direction + if (sortDir === 0) { + sortDir = 1; + $th.addClass('h5p-reverse'); + } + else { + sortDir = 0; + $th.removeClass('h5p-reverse'); + } + } + else { + // Change sorting column + $sortCol.removeClass('h5p-sort').removeClass('h5p-reverse'); + $sortCol = $th.addClass('h5p-sort'); + sortCol = id; + sortDir = 0; + } + + sortByCol({ + by: sortCol, + dir: sortDir + }); + }; + + /** + * Set table headers. + * + * @public + * @param {Array} cols + * Table header data. Can be strings or objects with options like + * "text" and "sortable". E.g. + * [{text: 'Col 1', sortable: true}, 'Col 2', 'Col 3'] + * @param {Function} sort Callback which is runned when sorting changes + * @param {Object} [order] + */ + this.setHeaders = function (cols, sort, order) { + numCols = cols.length; + sortByCol = sort; + + if (order) { + sortCol = order.by; + sortDir = order.dir; + } + + // Create new head + var $newThead = $('
      ', { + html: rows[i][j] + }).appendTo($tr); + } + } + + $tbody.replaceWith($newTbody); + $tbody = $newTbody; + + return $tbody; + }; + + /** + * Set custom table body content. This can be a message or a throbber. + * Will cover all table columns. + * + * @public + * @param {jQuery} $content Custom content + */ + this.setBody = function ($content) { + var $newTbody = $('
      ', { + colspan: numCols + }).append($content).appendTo($tr); + $tbody.replaceWith($newTbody); + $tbody = $newTbody; + }; + + /** + * Set custom table foot content. This can be a pagination widget. + * Will cover all table columns. + * + * @public + * @param {jQuery} $content Custom content + */ + this.setFoot = function ($content) { + var $newTfoot = $('
      ', { + colspan: numCols + }).append($content).appendTo($tr); + $tfoot.replaceWith($newTfoot); + }; + + + /** + * Appends the table to the given container. + * + * @public + * @param {jQuery} $container + */ + this.appendTo = function ($container) { + $table.appendTo($container); + }; + }; + + /** + * Generic pagination class. Creates a useful pagination widget. + * + * @class + * @param {Number} num Total number of items to pagiate. + * @param {Number} limit Number of items to dispaly per page. + * @param {Function} goneTo + * Callback which is fired when the user wants to go to another page. + * @param {Object} l10n + * Localization / translations. e.g. + * { + * currentPage: 'Page $current of $total', + * nextPage: 'Next page', + * previousPage: 'Previous page' + * } + */ + H5PUtils.Pagination = function (num, limit, goneTo, l10n) { + var current = 0; + var pages = Math.ceil(num / limit); + + // Create components + + // Previous button + var $left = $(''; + } + if (contentData.displayOptions.export && contentData.displayOptions.copy) { + html += '
      or
      '; + } + if (contentData.displayOptions.copy) { + html += ''; + } + + const dialog = new H5P.Dialog('reuse', H5P.t('reuseContent'), html, $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { + H5P.jQuery('More Info').click(function (e) { + e.stopPropagation(); + }).appendTo($dialog.find('h2')); + $dialog.find('.h5p-download-button').click(function () { + window.location.href = contentData.exportUrl; + instance.triggerXAPI('downloaded'); + dialog.close(); + }); + $dialog.find('.h5p-copy-button').click(function () { + const item = new H5P.ClipboardItem(library); + item.contentId = contentId; + H5P.setClipboard(item); + instance.triggerXAPI('copied'); + dialog.close(); + H5P.attachToastTo( + H5P.jQuery('.h5p-content:first')[0], + H5P.t('contentCopied'), + { + position: { + horizontal: 'centered', + vertical: 'centered', + noOverflowX: true + } + } + ); + }); + H5P.trigger(instance, 'resize'); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Display a dialog containing the embed code. + * + * @param {H5P.jQuery} $element + * Element to insert dialog after. + * @param {string} embedCode + * The embed code. + * @param {string} resizeCode + * The advanced resize code + * @param {Object} size + * The content's size. + * @param {number} size.width + * @param {number} size.height + */ +H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) { + var fullEmbedCode = embedCode + resizeCode; + var dialog = new H5P.Dialog('embed', H5P.t('embed'), '' + H5P.t('size') + ': × px
      ' + H5P.t('showAdvanced') + '

      ' + H5P.t('advancedHelp') + '

      ', $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { + var $inner = $dialog.find('.h5p-inner'); + var $scroll = $inner.find('.h5p-scroll-content'); + var diff = $scroll.outerHeight() - $scroll.innerHeight(); + var positionInner = function () { + H5P.trigger(instance, 'resize'); + }; + + // Handle changing of width/height + var $w = $dialog.find('.h5p-embed-size:eq(0)'); + var $h = $dialog.find('.h5p-embed-size:eq(1)'); + var getNum = function ($e, d) { + var num = parseFloat($e.val()); + if (isNaN(num)) { + return d; + } + return Math.ceil(num); + }; + var updateEmbed = function () { + $dialog.find('.h5p-embed-code-container:first').val(fullEmbedCode.replace(':w', getNum($w, size.width)).replace(':h', getNum($h, size.height))); + }; + + $w.change(updateEmbed); + $h.change(updateEmbed); + updateEmbed(); + + // Select text and expand textareas + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px').focus(function () { + H5P.jQuery(this).select(); + }); + }); + $dialog.find('.h5p-embed-code-container').eq(0).select(); + positionInner(); + + // Expand advanced embed + var expand = function () { + var $expander = H5P.jQuery(this); + var $content = $expander.next(); + if ($content.is(':visible')) { + $expander.removeClass('h5p-open').text(H5P.t('showAdvanced')); + $content.hide(); + } + else { + $expander.addClass('h5p-open').text(H5P.t('hideAdvanced')); + $content.show(); + } + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px'); + }); + positionInner(); + }; + $dialog.find('.h5p-expander').click(expand).keypress(function (event) { + if (event.keyCode === 32) { + expand.apply(this); + } + }); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Show a toast message. + * + * The reference element could be dom elements the toast should be attached to, + * or e.g. the document body for general toast messages. + * + * @param {DOM} element Reference element to show toast message for. + * @param {string} message Message to show. + * @param {object} [config] Configuration. + * @param {string} [config.style=h5p-toast] Style name for the tooltip. + * @param {number} [config.duration=3000] Toast message length in ms. + * @param {object} [config.position] Relative positioning of the toast. + * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [config.position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. + * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. + */ +H5P.attachToastTo = function (element, message, config) { + if (element === undefined || message === undefined) { + return; + } + + const eventPath = function (evt) { + var path = (evt.composedPath && evt.composedPath()) || evt.path; + var target = evt.target; + + if (path != null) { + // Safari doesn't include Window, but it should. + return (path.indexOf(window) < 0) ? path.concat(window) : path; + } + + if (target === window) { + return [window]; + } + + function getParents(node, memo) { + memo = memo || []; + var parentNode = node.parentNode; + + if (!parentNode) { + return memo; + } + else { + return getParents(parentNode, memo.concat(parentNode)); + } + } + + return [target].concat(getParents(target), window); + }; + + /** + * Handle click while toast is showing. + */ + const clickHandler = function (event) { + /* + * A common use case will be to attach toasts to buttons that are clicked. + * The click would remove the toast message instantly without this check. + * Children of the clicked element are also ignored. + */ + var path = eventPath(event); + if (path.indexOf(element) !== -1) { + return; + } + clearTimeout(timer); + removeToast(); + }; + + + + /** + * Remove the toast message. + */ + const removeToast = function () { + document.removeEventListener('click', clickHandler); + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }; + + /** + * Get absolute coordinates for the toast. + * + * @param {DOM} element Reference element to show toast message for. + * @param {DOM} toast Toast element. + * @param {object} [position={}] Relative positioning of the toast message. + * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. + * @return {object} + */ + const getToastCoordinates = function (element, toast, position) { + position = position || {}; + position.offsetHorizontal = position.offsetHorizontal || 0; + position.offsetVertical = position.offsetVertical || 0; + + const toastRect = toast.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + let left = 0; + let top = 0; + + // Compute horizontal position + switch (position.horizontal) { + case 'before': + left = elementRect.left - toastRect.width - position.offsetHorizontal; + break; + case 'after': + left = elementRect.left + elementRect.width + position.offsetHorizontal; + break; + case 'left': + left = elementRect.left + position.offsetHorizontal; + break; + case 'right': + left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; + break; + case 'centered': + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + break; + default: + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + } + + // Compute vertical position + switch (position.vertical) { + case 'above': + top = elementRect.top - toastRect.height - position.offsetVertical; + break; + case 'below': + top = elementRect.top + elementRect.height + position.offsetVertical; + break; + case 'top': + top = elementRect.top + position.offsetVertical; + break; + case 'bottom': + top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; + break; + case 'centered': + top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; + break; + default: + top = elementRect.top + elementRect.height + position.offsetVertical; + } + + // Prevent overflow + const overflowElement = document.body; + const bounds = overflowElement.getBoundingClientRect(); + if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { + left = bounds.x; + } + if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { + left = bounds.x + bounds.width - toastRect.width; + } + if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { + top = bounds.y; + } + if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { + left = bounds.y + bounds.height - toastRect.height; + } + + return {left: left, top: top}; + }; + + // Sanitization + config = config || {}; + config.style = config.style || 'h5p-toast'; + config.duration = config.duration || 3000; + + // Build toast + const toast = document.createElement('div'); + toast.setAttribute('id', config.style); + toast.classList.add('h5p-toast-disabled'); + toast.classList.add(config.style); + + const msg = document.createElement('span'); + msg.innerHTML = message; + toast.appendChild(msg); + + document.body.appendChild(toast); + + // The message has to be set before getting the coordinates + const coordinates = getToastCoordinates(element, toast, config.position); + toast.style.left = Math.round(coordinates.left) + 'px'; + toast.style.top = Math.round(coordinates.top) + 'px'; + + toast.classList.remove('h5p-toast-disabled'); + const timer = setTimeout(removeToast, config.duration); + + // The toast can also be removed by clicking somewhere + document.addEventListener('click', clickHandler); +}; + +/** + * Copyrights for a H5P Content Library. + * + * @class + */ +H5P.ContentCopyrights = function () { + var label; + var media = []; + var content = []; + + /** + * Set label. + * + * @param {string} newLabel + */ + this.setLabel = function (newLabel) { + label = newLabel; + }; + + /** + * Add sub content. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMedia = function (newMedia) { + if (newMedia !== undefined) { + media.push(newMedia); + } + }; + + /** + * Add sub content in front. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMediaInFront = function (newMedia) { + if (newMedia !== undefined) { + media.unshift(newMedia); + } + }; + + /** + * Add sub content. + * + * @param {H5P.ContentCopyrights} newContent + */ + this.addContent = function (newContent) { + if (newContent !== undefined) { + content.push(newContent); + } + }; + + /** + * Print content copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + // Add media rights + for (var i = 0; i < media.length; i++) { + html += media[i]; + } + + // Add sub content rights + for (i = 0; i < content.length; i++) { + html += content[i]; + } + + + if (html !== '') { + // Add a label to this info + if (label !== undefined) { + html = '

      ' + label + '

      ' + html; + } + + // Add wrapper + html = '
      ' + html + '
      '; + } + + return html; + }; +}; + +/** + * A ordered list of copyright fields for media. + * + * @class + * @param {Object} copyright + * Copyright information fields. + * @param {Object} [labels] + * Translation of labels. + * @param {Array} [order] + * Order of the fields. + * @param {Object} [extraFields] + * Add extra copyright fields. + */ +H5P.MediaCopyright = function (copyright, labels, order, extraFields) { + var thumbnail; + var list = new H5P.DefinitionList(); + + /** + * Get translated label for field. + * + * @private + * @param {string} fieldName + * @returns {string} + */ + var getLabel = function (fieldName) { + if (labels === undefined || labels[fieldName] === undefined) { + return H5P.t(fieldName); + } + + return labels[fieldName]; + }; + + /** + * Get humanized value for the license field. + * + * @private + * @param {string} license + * @param {string} [version] + * @returns {string} + */ + var humanizeLicense = function (license, version) { + var copyrightLicense = H5P.copyrightLicenses[license]; + + // Build license string + var value = ''; + if (!(license === 'PD' && version)) { + // Add license label + value += (copyrightLicense.hasOwnProperty('label') ? copyrightLicense.label : copyrightLicense); + } + + // Check for version info + var versionInfo; + if (copyrightLicense.versions) { + if (copyrightLicense.versions.default && (!version || !copyrightLicense.versions[version])) { + version = copyrightLicense.versions.default; + } + if (version && copyrightLicense.versions[version]) { + versionInfo = copyrightLicense.versions[version]; + } + } + + if (versionInfo) { + // Add license version + if (value) { + value += ' '; + } + value += (versionInfo.hasOwnProperty('label') ? versionInfo.label : versionInfo); + } + + // Add link if specified + var link; + if (copyrightLicense.hasOwnProperty('link')) { + link = copyrightLicense.link.replace(':version', copyrightLicense.linkVersions ? copyrightLicense.linkVersions[version] : version); + } + else if (versionInfo && copyrightLicense.hasOwnProperty('link')) { + link = versionInfo.link; + } + if (link) { + value = '' + value + ''; + } + + // Generate parenthesis + var parenthesis = ''; + if (license !== 'PD' && license !== 'C') { + parenthesis += license; + } + if (version && version !== 'CC0 1.0') { + if (parenthesis && license !== 'GNU GPL') { + parenthesis += ' '; + } + parenthesis += version; + } + if (parenthesis) { + value += ' (' + parenthesis + ')'; + } + if (license === 'C') { + value += ' ©'; + } + + return value; + }; + + if (copyright !== undefined) { + // Add the extra fields + for (var field in extraFields) { + if (extraFields.hasOwnProperty(field)) { + copyright[field] = extraFields[field]; + } + } + + if (order === undefined) { + // Set default order + order = ['contentType', 'title', 'license', 'author', 'year', 'source', 'licenseExtras', 'changes']; + } + + for (var i = 0; i < order.length; i++) { + var fieldName = order[i]; + if (copyright[fieldName] !== undefined && copyright[fieldName] !== '') { + var humanValue = copyright[fieldName]; + if (fieldName === 'license') { + humanValue = humanizeLicense(copyright.license, copyright.version); + } + if (fieldName === 'source') { + humanValue = (humanValue) ? '' + humanValue + '' : undefined; + } + list.add(new H5P.Field(getLabel(fieldName), humanValue)); + } + } + } + + /** + * Set thumbnail. + * + * @param {H5P.Thumbnail} newThumbnail + */ + this.setThumbnail = function (newThumbnail) { + thumbnail = newThumbnail; + }; + + /** + * Checks if this copyright is undisclosed. + * I.e. only has the license attribute set, and it's undisclosed. + * + * @returns {boolean} + */ + this.undisclosed = function () { + if (list.size() === 1) { + var field = list.get(0); + if (field.getLabel() === getLabel('license') && field.getValue() === humanizeLicense('U')) { + return true; + } + } + return false; + }; + + /** + * Print media copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + if (this.undisclosed()) { + return html; // No need to print a copyright with a single undisclosed license. + } + + if (thumbnail !== undefined) { + html += thumbnail; + } + html += list; + + if (html !== '') { + html = ''; + } + + return html; + }; +}; + +/** + * A simple and elegant class for creating thumbnails of images. + * + * @class + * @param {string} source + * @param {number} width + * @param {number} height + */ +H5P.Thumbnail = function (source, width, height) { + var thumbWidth, thumbHeight = 100; + if (width !== undefined) { + thumbWidth = Math.round(thumbHeight * (width / height)); + } + + /** + * Print thumbnail. + * + * @returns {string} HTML. + */ + this.toString = function () { + return '' + H5P.t('thumbnail') + ''; + }; +}; + +/** + * Simple data structure class for storing a single field. + * + * @class + * @param {string} label + * @param {string} value + */ +H5P.Field = function (label, value) { + /** + * Public. Get field label. + * + * @returns {String} + */ + this.getLabel = function () { + return label; + }; + + /** + * Public. Get field value. + * + * @returns {String} + */ + this.getValue = function () { + return value; + }; +}; + +/** + * Simple class for creating a definition list. + * + * @class + */ +H5P.DefinitionList = function () { + var fields = []; + + /** + * Add field to list. + * + * @param {H5P.Field} field + */ + this.add = function (field) { + fields.push(field); + }; + + /** + * Get Number of fields. + * + * @returns {number} + */ + this.size = function () { + return fields.length; + }; + + /** + * Get field at given index. + * + * @param {number} index + * @returns {H5P.Field} + */ + this.get = function (index) { + return fields[index]; + }; + + /** + * Print definition list. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + html += '
      ' + field.getLabel() + '
      ' + field.getValue() + '
      '; + } + return (html === '' ? html : '
      ' + html + '
      '); + }; +}; + +/** + * THIS FUNCTION/CLASS IS DEPRECATED AND WILL BE REMOVED. + * + * Helper object for keeping coordinates in the same format all over. + * + * @deprecated + * Will be removed march 2016. + * @class + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h + */ +H5P.Coords = function (x, y, w, h) { + if ( !(this instanceof H5P.Coords) ) + return new H5P.Coords(x, y, w, h); + + /** @member {number} */ + this.x = 0; + /** @member {number} */ + this.y = 0; + /** @member {number} */ + this.w = 1; + /** @member {number} */ + this.h = 1; + + if (typeof(x) === 'object') { + this.x = x.x; + this.y = x.y; + this.w = x.w; + this.h = x.h; + } + else { + if (x !== undefined) { + this.x = x; + } + if (y !== undefined) { + this.y = y; + } + if (w !== undefined) { + this.w = w; + } + if (h !== undefined) { + this.h = h; + } + } + return this; +}; + +/** + * Parse library string into values. + * + * @param {string} library + * library in the format "machineName majorVersion.minorVersion" + * @returns {Object} + * library as an object with machineName, majorVersion and minorVersion properties + * return false if the library parameter is invalid + */ +H5P.libraryFromString = function (library) { + var regExp = /(.+)\s(\d+)\.(\d+)$/g; + var res = regExp.exec(library); + if (res !== null) { + return { + 'machineName': res[1], + 'majorVersion': parseInt(res[2]), + 'minorVersion': parseInt(res[3]) + }; + } + else { + return false; + } +}; + +/** + * Get the path to the library + * + * @param {string} library + * The library identifier in the format "machineName-majorVersion.minorVersion". + * @returns {string} + * The full path to the library. + */ +H5P.getLibraryPath = function (library) { + if (H5PIntegration.urlLibraries !== undefined) { + // This is an override for those implementations that has a different libraries URL, e.g. Moodle + return H5PIntegration.urlLibraries + '/' + library; + } + else { + return H5PIntegration.url + '/libraries/' + library; + } +}; + +/** + * Recursivly clone the given object. + * + * @param {Object|Array} object + * Object to clone. + * @param {boolean} [recursive] + * @returns {Object|Array} + * A clone of object. + */ +H5P.cloneObject = function (object, recursive) { + // TODO: Consider if this needs to be in core. Doesn't $.extend do the same? + var clone = object instanceof Array ? [] : {}; + + for (var i in object) { + if (object.hasOwnProperty(i)) { + if (recursive !== undefined && recursive && typeof object[i] === 'object') { + clone[i] = H5P.cloneObject(object[i], recursive); + } + else { + clone[i] = object[i]; + } + } + } + + return clone; +}; + +/** + * Remove all empty spaces before and after the value. + * + * @param {string} value + * @returns {string} + */ +H5P.trim = function (value) { + return value.replace(/^\s+|\s+$/g, ''); + + // TODO: Only include this or String.trim(). What is best? + // I'm leaning towards implementing the missing ones: http://kangax.github.io/compat-table/es5/ + // So should we make this function deprecated? +}; + +/** + * Check if JavaScript path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.jsLoaded = function (path) { + H5PIntegration.loadedJs = H5PIntegration.loadedJs || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedJs) !== -1; +}; + +/** + * Check if styles path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.cssLoaded = function (path) { + H5PIntegration.loadedCss = H5PIntegration.loadedCss || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedCss) !== -1; +}; + +/** + * Shuffle an array in place. + * + * @param {Array} array + * Array to shuffle + * @returns {Array} + * The passed array is returned for chaining. + */ +H5P.shuffleArray = function (array) { + // TODO: Consider if this should be a part of core. I'm guessing very few libraries are going to use it. + if (!(array instanceof Array)) { + return; + } + + var i = array.length, j, tempi, tempj; + if ( i === 0 ) return false; + while ( --i ) { + j = Math.floor( Math.random() * ( i + 1 ) ); + tempi = array[i]; + tempj = array[j]; + array[i] = tempj; + array[j] = tempi; + } + return array; +}; + +/** + * Post finished results for user. + * + * @deprecated + * Do not use this function directly, trigger the finish event instead. + * Will be removed march 2016 + * @param {number} contentId + * Identifies the content + * @param {number} score + * Achieved score/points + * @param {number} maxScore + * The maximum score/points that can be achieved + * @param {number} [time] + * Reported time consumption/usage + */ +H5P.setFinished = function (contentId, score, maxScore, time) { + var validScore = typeof score === 'number' || score instanceof Number; + if (validScore && H5PIntegration.postUserStatistics === true) { + /** + * Return unix timestamp for the given JS Date. + * + * @private + * @param {Date} date + * @returns {Number} + */ + var toUnix = function (date) { + return Math.round(date.getTime() / 1000); + }; + + // Post the results + const data = { + contentId: contentId, + score: score, + maxScore: maxScore, + opened: toUnix(H5P.opened[contentId]), + finished: toUnix(new Date()), + time: time + }; + H5P.jQuery.post(H5PIntegration.ajax.setFinished, data) + .fail(function () { + H5P.offlineRequestQueue.add(H5PIntegration.ajax.setFinished, data); + }); + } +}; + +// Add indexOf to browsers that lack them. (IEs) +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (needle) { + for (var i = 0; i < this.length; i++) { + if (this[i] === needle) { + return i; + } + } + return -1; + }; +} + +// Need to define trim() since this is not available on older IEs, +// and trim is used in several libs +if (String.prototype.trim === undefined) { + String.prototype.trim = function () { + return H5P.trim(this); + }; +} + +/** + * Trigger an event on an instance + * + * Helper function that triggers an event if the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to trigger + * @param {*} data + * @param {Object} extras + */ +H5P.trigger = function (instance, eventType, data, extras) { + // Try new event system first + if (instance.trigger !== undefined) { + instance.trigger(eventType, data, extras); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.trigger !== undefined) { + instance.$.trigger(eventType); + } +}; + +/** + * Register an event handler + * + * Helper function that registers an event handler for an event type if + * the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to listen for + * @param {H5P.EventCallback} handler + * Callback that gets triggered for events of the specified type + */ +H5P.on = function (instance, eventType, handler) { + // Try new event system first + if (instance.on !== undefined) { + instance.on(eventType, handler); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.on !== undefined) { + instance.$.on(eventType, handler); + } +}; + +/** + * Generate random UUID + * + * @returns {string} UUID + */ +H5P.createUUID = function () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { + var random = Math.random()*16|0, newChar = char === 'x' ? random : (random&0x3|0x8); + return newChar.toString(16); + }); +}; + +/** + * Create title + * + * @param {string} rawTitle + * @param {number} maxLength + * @returns {string} + */ +H5P.createTitle = function (rawTitle, maxLength) { + if (!rawTitle) { + return ''; + } + if (maxLength === undefined) { + maxLength = 60; + } + var title = H5P.jQuery('
      ') + .text( + // Strip tags + rawTitle.replace(/(<([^>]+)>)/ig,"") + // Escape + ).text(); + if (title.length > maxLength) { + title = title.substr(0, maxLength - 3) + '...'; + } + return title; +}; + +// Wrap in privates +(function ($) { + + /** + * Creates ajax requests for inserting, updateing and deleteing + * content user data. + * + * @private + * @param {number} contentId What content to store the data for. + * @param {string} dataType Identifies the set of data for this content. + * @param {string} subContentId Identifies sub content + * @param {function} [done] Callback when ajax is done. + * @param {object} [data] To be stored for future use. + * @param {boolean} [preload=false] Data is loaded when content is loaded. + * @param {boolean} [invalidate=false] Data is invalidated when content changes. + * @param {boolean} [async=true] + */ + function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + if (H5PIntegration.user === undefined) { + // Not logged in, no use in saving. + done('Not signed in.'); + return; + } + + var options = { + url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), + dataType: 'json', + async: async === undefined ? true : async + }; + if (data !== undefined) { + options.type = 'POST'; + options.data = { + data: (data === null ? 0 : data), + preload: (preload ? 1 : 0), + invalidate: (invalidate ? 1 : 0) + }; + } + else { + options.type = 'GET'; + } + if (done !== undefined) { + options.error = function (xhr, error) { + done(error); + }; + options.success = function (response) { + if (!response.success) { + done(response.message); + return; + } + + if (response.data === false || response.data === undefined) { + done(); + return; + } + + done(undefined, response.data); + }; + } + + $.ajax(options); + } + + /** + * Get user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {function} done + * Callback with error and data parameters. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.getUserData = function (contentId, dataId, done, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + H5PIntegration.contents = H5PIntegration.contents || {}; + var content = H5PIntegration.contents['cid-' + contentId] || {}; + var preloadedData = content.contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] !== undefined) { + if (preloadedData[subContentId][dataId] === 'RESET') { + done(undefined, null); + return; + } + try { + done(undefined, JSON.parse(preloadedData[subContentId][dataId])); + } + catch (err) { + done(err); + } + } + else { + contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { + if (err || data === undefined) { + done(err, data); + return; // Error or no data + } + + // Cache in preloaded + if (content.contentUserData === undefined) { + content.contentUserData = preloadedData = {}; + } + if (preloadedData[subContentId] === undefined) { + preloadedData[subContentId] = {}; + } + preloadedData[subContentId][dataId] = data; + + // Done. Try to decode JSON + try { + done(undefined, JSON.parse(data)); + } + catch (e) { + done(e); + } + }); + } + }; + + /** + * Async error handling. + * + * @callback H5P.ErrorCallback + * @param {*} error + */ + + /** + * Set user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {Object} data + * The data that is to be stored. + * @param {Object} [extras] + * Extra properties + * @param {string} [extras.subContentId] + * Identifies which data belongs to sub content. + * @param {boolean} [extras.preloaded=true] + * If the data should be loaded when content is loaded. + * @param {boolean} [extras.deleteOnChange=false] + * If the data should be invalidated when the content changes. + * @param {H5P.ErrorCallback} [extras.errorCallback] + * Callback with error as parameters. + * @param {boolean} [extras.async=true] + */ + H5P.setUserData = function (contentId, dataId, data, extras) { + var options = H5P.jQuery.extend(true, {}, { + subContentId: 0, + preloaded: true, + deleteOnChange: false, + async: true + }, extras); + + try { + data = JSON.stringify(data); + } + catch (err) { + if (options.errorCallback) { + options.errorCallback(err); + } + return; // Failed to serialize. + } + + var content = H5PIntegration.contents['cid-' + contentId]; + if (content === undefined) { + content = H5PIntegration.contents['cid-' + contentId] = {}; + } + if (!content.contentUserData) { + content.contentUserData = {}; + } + var preloadedData = content.contentUserData; + if (preloadedData[options.subContentId] === undefined) { + preloadedData[options.subContentId] = {}; + } + if (data === preloadedData[options.subContentId][dataId]) { + return; // No need to save this twice. + } + + preloadedData[options.subContentId][dataId] = data; + contentUserDataAjax(contentId, dataId, options.subContentId, function (error) { + if (options.errorCallback && error) { + options.errorCallback(error); + } + }, data, options.preloaded, options.deleteOnChange, options.async); + }; + + /** + * Delete user data for given content. + * + * @param {number} contentId + * What content to remove data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.deleteUserData = function (contentId, dataId, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + // Remove from preloaded/cache + var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { + delete preloadedData[subContentId][dataId]; + } + + contentUserDataAjax(contentId, dataId, subContentId, undefined, null); + }; + + /** + * Function for getting content for a certain ID + * + * @param {number} contentId + * @return {Object} + */ + H5P.getContentForInstance = function (contentId) { + var key = 'cid-' + contentId; + var exists = H5PIntegration && H5PIntegration.contents && + H5PIntegration.contents[key]; + + return exists ? H5PIntegration.contents[key] : undefined; + }; + + /** + * Prepares the content parameters for storing in the clipboard. + * + * @class + * @param {Object} parameters The parameters for the content to store + * @param {string} [genericProperty] If only part of the parameters are generic, which part + * @param {string} [specificKey] If the parameters are specific, what content type does it fit + * @returns {Object} Ready for the clipboard + */ + H5P.ClipboardItem = function (parameters, genericProperty, specificKey) { + var self = this; + + /** + * Set relative dimensions when params contains a file with a width and a height. + * Very useful to be compatible with wysiwyg editors. + * + * @private + */ + var setDimensionsFromFile = function () { + if (!self.generic) { + return; + } + var params = self.specific[self.generic]; + if (!params.params.file || !params.params.file.width || !params.params.file.height) { + return; + } + + self.width = 20; // % + self.height = (params.params.file.height / params.params.file.width) * self.width; + }; + + if (!genericProperty) { + genericProperty = 'action'; + parameters = { + action: parameters + }; + } + + self.specific = parameters; + + if (genericProperty && parameters[genericProperty]) { + self.generic = genericProperty; + } + if (specificKey) { + self.from = specificKey; + } + + if (window.H5PEditor && H5PEditor.contentId) { + self.contentId = H5PEditor.contentId; + } + + if (!self.specific.width && !self.specific.height) { + setDimensionsFromFile(); + } + }; + + /** + * Store item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|*} clipboardItem + */ + H5P.clipboardify = function (clipboardItem) { + if (!(clipboardItem instanceof H5P.ClipboardItem)) { + clipboardItem = new H5P.ClipboardItem(clipboardItem); + } + H5P.setClipboard(clipboardItem); + }; + + /** + * Retrieve parsed clipboard data. + * + * @return {Object} + */ + H5P.getClipboard = function () { + return parseClipboard(); + }; + + /** + * Set item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|object} clipboardItem - Data to be set. + */ + H5P.setClipboard = function (clipboardItem) { + localStorage.setItem('h5pClipboard', JSON.stringify(clipboardItem)); + + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: false}); + }; + + /** + * Get config for a library + * + * @param string machineName + * @return Object + */ + H5P.getLibraryConfig = function (machineName) { + var hasConfig = H5PIntegration.libraryConfig && H5PIntegration.libraryConfig[machineName]; + return hasConfig ? H5PIntegration.libraryConfig[machineName] : {}; + }; + + /** + * Get item from the H5P Clipboard. + * + * @private + * @return {Object} + */ + var parseClipboard = function () { + var clipboardData = localStorage.getItem('h5pClipboard'); + if (!clipboardData) { + return; + } + + // Try to parse clipboard dat + try { + clipboardData = JSON.parse(clipboardData); + } + catch (err) { + console.error('Unable to parse JSON from clipboard.', err); + return; + } + + // Update file URLs and reset content Ids + recursiveUpdate(clipboardData.specific, function (path) { + var isTmpFile = (path.substr(-4, 4) === '#tmp'); + if (!isTmpFile && clipboardData.contentId && !path.match(/^https?:\/\//i)) { + // Comes from existing content + + if (H5PEditor.contentId) { + // .. to existing content + return '../' + clipboardData.contentId + '/' + path; + } + else { + // .. to new content + return (H5PEditor.contentRelUrl ? H5PEditor.contentRelUrl : '../content/') + clipboardData.contentId + '/' + path; + } + } + return path; // Will automatically be looked for in tmp folder + }); + + + if (clipboardData.generic) { + // Use reference instead of key + clipboardData.generic = clipboardData.specific[clipboardData.generic]; + } + + return clipboardData; + }; + + /** + * Update file URLs and reset content IDs. + * Useful when copying content. + * + * @private + * @param {object} params Reference + * @param {function} handler Modifies the path to work when pasted + */ + var recursiveUpdate = function (params, handler) { + for (var prop in params) { + if (params.hasOwnProperty(prop) && params[prop] instanceof Object) { + var obj = params[prop]; + if (obj.path !== undefined && obj.mime !== undefined) { + obj.path = handler(obj.path); + } + else { + if (obj.library !== undefined && obj.subContentId !== undefined) { + // Avoid multiple content with same ID + delete obj.subContentId; + } + recursiveUpdate(obj, handler); + } + } + } + }; + + // Init H5P when page is fully loadded + $(document).ready(function () { + + window.addEventListener('storage', function (event) { + // Pick up clipboard changes from other tabs + if (event.key === 'h5pClipboard') { + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: event.newValue === null}); + } + }); + + var ccVersions = { + 'default': '4.0', + '4.0': H5P.t('licenseCC40'), + '3.0': H5P.t('licenseCC30'), + '2.5': H5P.t('licenseCC25'), + '2.0': H5P.t('licenseCC20'), + '1.0': H5P.t('licenseCC10'), + }; + + /** + * Maps copyright license codes to their human readable counterpart. + * + * @type {Object} + */ + H5P.copyrightLicenses = { + 'U': H5P.t('licenseU'), + 'CC BY': { + label: H5P.t('licenseCCBY'), + link: 'http://creativecommons.org/licenses/by/:version', + versions: ccVersions + }, + 'CC BY-SA': { + label: H5P.t('licenseCCBYSA'), + link: 'http://creativecommons.org/licenses/by-sa/:version', + versions: ccVersions + }, + 'CC BY-ND': { + label: H5P.t('licenseCCBYND'), + link: 'http://creativecommons.org/licenses/by-nd/:version', + versions: ccVersions + }, + 'CC BY-NC': { + label: H5P.t('licenseCCBYNC'), + link: 'http://creativecommons.org/licenses/by-nc/:version', + versions: ccVersions + }, + 'CC BY-NC-SA': { + label: H5P.t('licenseCCBYNCSA'), + link: 'http://creativecommons.org/licenses/by-nc-sa/:version', + versions: ccVersions + }, + 'CC BY-NC-ND': { + label: H5P.t('licenseCCBYNCND'), + link: 'http://creativecommons.org/licenses/by-nc-nd/:version', + versions: ccVersions + }, + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'GNU GPL': { + label: H5P.t('licenseGPL'), + link: 'http://www.gnu.org/licenses/gpl-:version-standalone.html', + linkVersions: { + 'v3': '3.0', + 'v2': '2.0', + 'v1': '1.0' + }, + versions: { + 'default': 'v3', + 'v3': H5P.t('licenseV3'), + 'v2': H5P.t('licenseV2'), + 'v1': H5P.t('licenseV1') + } + }, + 'PD': { + label: H5P.t('licensePD'), + versions: { + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + } + } + }, + 'ODC PDDL': 'Public Domain Dedication and Licence', + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + }, + 'C': H5P.t('licenseC'), + }; + + /** + * Indicates if H5P is embedded on an external page using iframe. + * @member {boolean} H5P.externalEmbed + */ + + // Relay events to top window. This must be done before H5P.init + // since events may be fired on initialization. + if (H5P.isFramed && H5P.externalEmbed === false) { + H5P.externalDispatcher.on('*', function (event) { + window.parent.H5P.externalDispatcher.trigger.call(this, event); + }); + } + + /** + * Prevent H5P Core from initializing. Must be overriden before document ready. + * @member {boolean} H5P.preventInit + */ + if (!H5P.preventInit) { + // Note that this start script has to be an external resource for it to + // load in correct order in IE9. + H5P.init(document.body); + } + + if (H5PIntegration.saveFreq !== false) { + // When was the last state stored + var lastStoredOn = 0; + // Store the current state of the H5P when leaving the page. + var storeCurrentState = function () { + // Make sure at least 250 ms has passed since last save + var currentTime = new Date().getTime(); + if (currentTime - lastStoredOn > 250) { + lastStoredOn = currentTime; + for (var i = 0; i < H5P.instances.length; i++) { + var instance = H5P.instances[i]; + if (instance.getCurrentState instanceof Function || + typeof instance.getCurrentState === 'function') { + var state = instance.getCurrentState(); + if (state !== undefined) { + // Async is not used to prevent the request from being cancelled. + H5P.setUserData(instance.contentId, 'state', state, {deleteOnChange: true, async: false}); + } + } + } + } + }; + // iPad does not support beforeunload, therefore using unload + H5P.$window.one('beforeunload unload', function () { + // Only want to do this once + H5P.$window.off('pagehide beforeunload unload'); + storeCurrentState(); + }); + // pagehide is used on iPad when tabs are switched + H5P.$window.on('pagehide', storeCurrentState); + } + }); + +})(H5P.jQuery); diff --git a/src/core/features/h5p/assets/js/jquery.js b/src/core/features/h5p/assets/js/jquery.js new file mode 100644 index 000000000..9583951e4 --- /dev/null +++ b/src/core/features/h5p/assets/js/jquery.js @@ -0,0 +1,13 @@ +/* The jQuery version has been modified to prevent warnings in the stores. This version has been patched to fix some security issues. */ +!function(e,t){"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(e,t){var n=[],r=e.document,i=n.slice,o=n.concat,a=n.push,s=n.indexOf,u={},l=u.toString,c=u.hasOwnProperty,f={},d=function(e,t){return new d.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,h=/^-ms-/,g=/-([\da-z])/gi,m=function(e,t){return t.toUpperCase()};function v(e){var t=!!e&&"length"in e&&e.length,n=d.type(e);return"function"!==n&&!d.isWindow(e)&&("array"===n||0===t||"number"==typeof t&&t>0&&t-1 in e)}d.fn=d.prototype={jquery:"123.4.5",constructor:d,selector:"",length:0,toArray:function(){return i.call(this)},get:function(e){return null!=e?e<0?this[e+this.length]:this[e]:i.call(this)},pushStack:function(e){var t=d.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e){return d.each(this,e)},map:function(e){return this.pushStack(d.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(i.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n=0},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},isPlainObject:function(e){var t;if(!e||"object"!==d.type(e)||e.nodeType||d.isWindow(e))return!1;try{if(e.constructor&&!c.call(e,"constructor")&&!c.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(e){return!1}if(!f.ownFirst)for(t in e)return c.call(e,t);for(t in e);return void 0===t||c.call(e,t)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?u[l.call(e)]||"object":typeof e},globalEval:function(t){t&&d.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(h,"ms-").replace(g,m)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t){var n,r=0;if(v(e))for(n=e.length;r+~]|"+O+")"+O+"*"),X=new RegExp("="+O+"*([^\\]'\"]*?)"+O+"*\\]","g"),U=new RegExp(B),V=new RegExp("^"+R+"$"),Y={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+P),PSEUDO:new RegExp("^"+B),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+O+"*(even|odd|(([+-]|)(\\d*)n|)"+O+"*(?:([+-]|)"+O+"*(\\d+)|))"+O+"*\\)|)","i"),bool:new RegExp("^(?:"+M+")$","i"),needsContext:new RegExp("^"+O+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+O+"*((?:-\\d)?\\d*)"+O+"*\\)|)(?=[^-]|$)","i")},J=/^(?:input|select|textarea|button)$/i,G=/^h\d$/i,Q=/^[^{]+\{\s*\[native \w/,K=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,Z=/[+~]/,ee=/'|\\/g,te=new RegExp("\\\\([\\da-f]{1,6}"+O+"?|("+O+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=function(){d()};try{q.apply(j=_.call(w.childNodes),w.childNodes),j[w.childNodes.length].nodeType}catch(e){q={apply:j.length?function(e,t){H.apply(e,_.call(t))}:function(e,t){for(var n=e.length,r=0;e[n++]=t[r++];);e.length=n-1}}}function ie(e,t,r,i){var o,s,l,c,f,h,v,y,T=t&&t.ownerDocument,C=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==C&&9!==C&&11!==C)return r;if(!i&&((t?t.ownerDocument||t:w)!==p&&d(t),t=t||p,g)){if(11!==C&&(h=K.exec(e)))if(o=h[1]){if(9===C){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(T&&(l=T.getElementById(o))&&x(t,l)&&l.id===o)return r.push(l),r}else{if(h[2])return q.apply(r,t.getElementsByTagName(e)),r;if((o=h[3])&&n.getElementsByClassName&&t.getElementsByClassName)return q.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!k[e+" "]&&(!m||!m.test(e))){if(1!==C)T=t,y=e;else if("object"!==t.nodeName.toLowerCase()){for((c=t.getAttribute("id"))?c=c.replace(ee,"\\$&"):t.setAttribute("id",c=b),s=(v=a(e)).length,f=V.test(c)?"#"+c:"[id='"+c+"']";s--;)v[s]=f+" "+ge(v[s]);y=v.join(","),T=Z.test(e)&&pe(t.parentNode)||t}if(y)try{return q.apply(r,T.querySelectorAll(y)),r}catch(e){}finally{c===b&&t.removeAttribute("id")}}}return u(e.replace(I,"$1"),t,r,i)}function oe(){var e=[];return function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}}function ae(e){return e[b]=!0,e}function se(e){var t=p.createElement("div");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function ue(e,t){for(var n=e.split("|"),i=n.length;i--;)r.attrHandle[n[i]]=t}function le(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&(~t.sourceIndex||A)-(~e.sourceIndex||A);if(r)return r;if(n)for(;n=n.nextSibling;)if(n===t)return-1;return e?1:-1}function ce(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function fe(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function de(e){return ae(function(t){return t=+t,ae(function(n,r){for(var i,o=e([],n.length,t),a=o.length;a--;)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function pe(e){return e&&void 0!==e.getElementsByTagName&&e}for(t in n=ie.support={},o=ie.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},d=ie.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==p&&9===a.nodeType&&a.documentElement?(h=(p=a).documentElement,g=!o(p),(i=p.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=se(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=se(function(e){return e.appendChild(p.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=Q.test(p.getElementsByClassName),n.getById=se(function(e){return h.appendChild(e).id=b,!p.getElementsByName||!p.getElementsByName(b).length}),n.getById?(r.find.ID=function(e,t){if(void 0!==t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}},r.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}}):(delete r.find.ID,r.filter.ID=function(e){var t=e.replace(te,ne);return function(e){var n=void 0!==e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}}),r.find.TAG=n.getElementsByTagName?function(e,t){return void 0!==t.getElementsByTagName?t.getElementsByTagName(e):n.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){for(;n=o[i++];)1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(e,t){if(void 0!==t.getElementsByClassName&&g)return t.getElementsByClassName(e)},v=[],m=[],(n.qsa=Q.test(p.querySelectorAll))&&(se(function(e){h.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&m.push("[*^$]="+O+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||m.push("\\["+O+"*(?:value|"+M+")"),e.querySelectorAll("[id~="+b+"-]").length||m.push("~="),e.querySelectorAll(":checked").length||m.push(":checked"),e.querySelectorAll("a#"+b+"+*").length||m.push(".#.+[+~]")}),se(function(e){var t=p.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&m.push("name"+O+"*[*^$|!~]?="),e.querySelectorAll(":enabled").length||m.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),m.push(",.*:")})),(n.matchesSelector=Q.test(y=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&se(function(e){n.disconnectedMatch=y.call(e,"div"),y.call(e,"[s!='']:x"),v.push("!=",B)}),m=m.length&&new RegExp(m.join("|")),v=v.length&&new RegExp(v.join("|")),t=Q.test(h.compareDocumentPosition),x=t||Q.test(h.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},S=t?function(e,t){if(e===t)return f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===p||e.ownerDocument===w&&x(w,e)?-1:t===p||t.ownerDocument===w&&x(w,t)?1:c?F(c,e)-F(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===p?-1:t===p?1:i?-1:o?1:c?F(c,e)-F(c,t):0;if(i===o)return le(e,t);for(n=e;n=n.parentNode;)a.unshift(n);for(n=t;n=n.parentNode;)s.unshift(n);for(;a[r]===s[r];)r++;return r?le(a[r],s[r]):a[r]===w?-1:s[r]===w?1:0},p):p},ie.matches=function(e,t){return ie(e,null,null,t)},ie.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&d(e),t=t.replace(X,"='$1']"),n.matchesSelector&&g&&!k[t+" "]&&(!v||!v.test(t))&&(!m||!m.test(t)))try{var r=y.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return ie(t,p,null,[e]).length>0},ie.contains=function(e,t){return(e.ownerDocument||e)!==p&&d(e),x(e,t)},ie.attr=function(e,t){(e.ownerDocument||e)!==p&&d(e);var i=r.attrHandle[t.toLowerCase()],o=i&&D.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},ie.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},ie.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(S),f){for(;t=e[o++];)t===e[o]&&(i=r.push(o));for(;i--;)e.splice(r[i],1)}return c=null,e},i=ie.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else for(;t=e[r++];)n+=i(t);return n},(r=ie.selectors={cacheLength:50,createPseudo:ae,match:Y,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||ie.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&ie.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return Y.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&U.test(n)&&(t=a(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+O+")"+e+"("+O+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||void 0!==e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=ie.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace(W," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,d,p,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,v=s&&t.nodeName.toLowerCase(),y=!u&&!s,x=!1;if(m){if(o){for(;g;){for(d=t;d=d[g];)if(s?d.nodeName.toLowerCase()===v:1===d.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&y){for(x=(p=(l=(c=(f=(d=m)[b]||(d[b]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]||[])[0]===T&&l[1])&&l[2],d=p&&m.childNodes[p];d=++p&&d&&d[g]||(x=p=0)||h.pop();)if(1===d.nodeType&&++x&&d===t){c[e]=[T,p,x];break}}else if(y&&(x=p=(l=(c=(f=(d=t)[b]||(d[b]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]||[])[0]===T&&l[1]),!1===x)for(;(d=++p&&d&&d[g]||(x=p=0)||h.pop())&&((s?d.nodeName.toLowerCase()!==v:1!==d.nodeType)||!++x||(y&&((c=(f=d[b]||(d[b]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]=[T,x]),d!==t)););return(x-=i)===r||x%r==0&&x/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||ie.error("unsupported pseudo: "+e);return i[b]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?ae(function(e,n){for(var r,o=i(e,t),a=o.length;a--;)e[r=F(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:ae(function(e){var t=[],n=[],r=s(e.replace(I,"$1"));return r[b]?ae(function(e,t,n,i){for(var o,a=r(e,null,i,[]),s=e.length;s--;)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:ae(function(e){return function(t){return ie(e,t).length>0}}),contains:ae(function(e){return e=e.replace(te,ne),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:ae(function(e){return V.test(e||"")||ie.error("unsupported lang: "+e),e=e.replace(te,ne).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return!1===e.disabled},disabled:function(e){return!0===e.disabled},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return G.test(e.nodeName)},input:function(e){return J.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:de(function(){return[0]}),last:de(function(e,t){return[t-1]}),eq:de(function(e,t,n){return[n<0?n+t:n]}),even:de(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:de(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){for(var i=e.length;i--;)if(!e[i](t,n,r))return!1;return!0}:e[0]}function ye(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;s-1&&(o[l]=!(a[l]=f))}}else v=ye(v===a?v.splice(h,v.length):v),i?i(null,a,v,u):q.apply(a,v)})}function be(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],s=a||r.relative[" "],u=a?1:0,c=me(function(e){return e===t},s,!0),f=me(function(e){return F(t,e)>-1},s,!0),d=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];u1&&ve(d),u>1&&ge(e.slice(0,u-1).concat({value:" "===e[u-2].type?"*":""})).replace(I,"$1"),n,u0,i=e.length>0,o=function(o,a,s,u,c){var f,h,m,v=0,y="0",x=o&&[],b=[],w=l,C=o||i&&r.find.TAG("*",c),E=T+=null==w?1:Math.random()||.1,N=C.length;for(c&&(l=a===p||a||c);y!==N&&null!=(f=C[y]);y++){if(i&&f){for(h=0,a||f.ownerDocument===p||(d(f),s=!g);m=e[h++];)if(m(f,a||p,s)){u.push(f);break}c&&(T=E)}n&&((f=!m&&f)&&v--,o&&x.push(f))}if(v+=y,n&&y!==v){for(h=0;m=t[h++];)m(x,b,a,s);if(o){if(v>0)for(;y--;)x[y]||b[y]||(b[y]=L.call(u));b=ye(b)}q.apply(u,b),c&&!o&&b.length>0&&v+t.length>1&&ie.uniqueSort(u)}return c&&(T=E,l=w),x};return n?ae(o):o}(o,i))).selector=e}return s},u=ie.select=function(e,t,i,o){var u,l,c,f,d,p="function"==typeof e&&e,h=!o&&a(e=p.selector||e);if(i=i||[],1===h.length){if((l=h[0]=h[0].slice(0)).length>2&&"ID"===(c=l[0]).type&&n.getById&&9===t.nodeType&&g&&r.relative[l[1].type]){if(!(t=(r.find.ID(c.matches[0].replace(te,ne),t)||[])[0]))return i;p&&(t=t.parentNode),e=e.slice(l.shift().value.length)}for(u=Y.needsContext.test(e)?0:l.length;u--&&(c=l[u],!r.relative[f=c.type]);)if((d=r.find[f])&&(o=d(c.matches[0].replace(te,ne),Z.test(l[0].type)&&pe(t.parentNode)||t))){if(l.splice(u,1),!(e=o.length&&ge(l)))return q.apply(i,o),i;break}}return(p||s(e,h))(o,t,!g,i,!t||Z.test(e)&&pe(t.parentNode)||t),i},n.sortStable=b.split("").sort(S).join("")===b,n.detectDuplicates=!!f,d(),n.sortDetached=se(function(e){return 1&e.compareDocumentPosition(p.createElement("div"))}),se(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||ue("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&se(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||ue("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),se(function(e){return null==e.getAttribute("disabled")})||ue(M,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),ie}(e);d.find=y,d.expr=y.selectors,d.expr[":"]=d.expr.pseudos,d.uniqueSort=d.unique=y.uniqueSort,d.text=y.getText,d.isXMLDoc=y.isXML,d.contains=y.contains;var x=function(e,t,n){for(var r=[],i=void 0!==n;(e=e[t])&&9!==e.nodeType;)if(1===e.nodeType){if(i&&d(e).is(n))break;r.push(e)}return r},b=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},w=d.expr.match.needsContext,T=/^<([\w-]+)\s*\/?>(?:<\/\1>|)$/,C=/^.[^:#\[\.,]*$/;function E(e,t,n){if(d.isFunction(t))return d.grep(e,function(e,r){return!!t.call(e,r,e)!==n});if(t.nodeType)return d.grep(e,function(e){return e===t!==n});if("string"==typeof t){if(C.test(t))return d.filter(t,e,n);t=d.filter(t,e)}return d.grep(e,function(e){return d.inArray(e,t)>-1!==n})}d.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?d.find.matchesSelector(r,e)?[r]:[]:d.find.matches(e,d.grep(t,function(e){return 1===e.nodeType}))},d.fn.extend({find:function(e){var t,n=[],r=this,i=r.length;if("string"!=typeof e)return this.pushStack(d(e).filter(function(){for(t=0;t1?d.unique(n):n)).selector=this.selector?this.selector+" "+e:e,n},filter:function(e){return this.pushStack(E(this,e||[],!1))},not:function(e){return this.pushStack(E(this,e||[],!0))},is:function(e){return!!E(this,"string"==typeof e&&w.test(e)?d(e):e||[],!1).length}});var N,k=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/;(d.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||N,"string"==typeof e){if(!(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:k.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof d?t[0]:t,d.merge(this,d.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),T.test(i[1])&&d.isPlainObject(t))for(i in t)d.isFunction(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}if((o=r.getElementById(i[2]))&&o.parentNode){if(o.id!==i[2])return N.find(e);this.length=1,this[0]=o}return this.context=r,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):d.isFunction(e)?void 0!==n.ready?n.ready(e):e(d):(void 0!==e.selector&&(this.selector=e.selector,this.context=e.context),d.makeArray(e,this))}).prototype=d.fn,N=d(r);var S=/^(?:parents|prev(?:Until|All))/,A={children:!0,contents:!0,next:!0,prev:!0};function D(e,t){do{e=e[t]}while(e&&1!==e.nodeType);return e}d.fn.extend({has:function(e){var t,n=d(e,this),r=n.length;return this.filter(function(){for(t=0;t-1:1===n.nodeType&&d.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?d.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?d.inArray(this[0],d(e)):d.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(d.uniqueSort(d.merge(this.get(),d(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),d.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return x(e,"parentNode")},parentsUntil:function(e,t,n){return x(e,"parentNode",n)},next:function(e){return D(e,"nextSibling")},prev:function(e){return D(e,"previousSibling")},nextAll:function(e){return x(e,"nextSibling")},prevAll:function(e){return x(e,"previousSibling")},nextUntil:function(e,t,n){return x(e,"nextSibling",n)},prevUntil:function(e,t,n){return x(e,"previousSibling",n)},siblings:function(e){return b((e.parentNode||{}).firstChild,e)},children:function(e){return b(e.firstChild)},contents:function(e){return d.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:d.merge([],e.childNodes)}},function(e,t){d.fn[e]=function(n,r){var i=d.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=d.filter(r,i)),this.length>1&&(A[e]||(i=d.uniqueSort(i)),S.test(e)&&(i=i.reverse())),this.pushStack(i)}});var j,L,H=/\S+/g;function q(){r.addEventListener?(r.removeEventListener("DOMContentLoaded",_),e.removeEventListener("load",_)):(r.detachEvent("onreadystatechange",_),e.detachEvent("onload",_))}function _(){(r.addEventListener||"load"===e.event.type||"complete"===r.readyState)&&(q(),d.ready())}for(L in d.Callbacks=function(e){e="string"==typeof e?function(e){var t={};return d.each(e.match(H)||[],function(e,n){t[n]=!0}),t}(e):d.extend({},e);var t,n,r,i,o=[],a=[],s=-1,u=function(){for(i=e.once,r=t=!0;a.length;s=-1)for(n=a.shift();++s-1;)o.splice(n,1),n<=s&&s--}),this},has:function(e){return e?d.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=!0,n||l.disable(),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||u()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l},d.extend({Deferred:function(e){var t=[["resolve","done",d.Callbacks("once memory"),"resolved"],["reject","fail",d.Callbacks("once memory"),"rejected"],["notify","progress",d.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return d.Deferred(function(n){d.each(t,function(t,o){var a=d.isFunction(e[t])&&e[t];i[o[1]](function(){var e=a&&a.apply(this,arguments);e&&d.isFunction(e.promise)?e.promise().progress(n.notify).done(n.resolve).fail(n.reject):n[o[0]+"With"](this===r?n.promise():this,a?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?d.extend(e,r):r}},i={};return r.pipe=r.then,d.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t,n,r,o=0,a=i.call(arguments),s=a.length,u=1!==s||e&&d.isFunction(e.promise)?s:0,l=1===u?e:d.Deferred(),c=function(e,n,r){return function(o){n[e]=this,r[e]=arguments.length>1?i.call(arguments):o,r===t?l.notifyWith(n,r):--u||l.resolveWith(n,r)}};if(s>1)for(t=new Array(s),n=new Array(s),r=new Array(s);o0||(j.resolveWith(r,[d]),d.fn.triggerHandler&&(d(r).triggerHandler("ready"),d(r).off("ready"))))}}),d.ready.promise=function(t){if(!j)if(j=d.Deferred(),"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll)e.setTimeout(d.ready);else if(r.addEventListener)r.addEventListener("DOMContentLoaded",_),e.addEventListener("load",_);else{r.attachEvent("onreadystatechange",_),e.attachEvent("onload",_);var n=!1;try{n=null==e.frameElement&&r.documentElement}catch(e){}n&&n.doScroll&&function t(){if(!d.isReady){try{n.doScroll("left")}catch(n){return e.setTimeout(t,50)}q(),d.ready()}}()}return j.promise(t)},d.ready.promise(),d(f))break;f.ownFirst="0"===L,f.inlineBlockNeedsLayout=!1,d(function(){var e,t,n,i;(n=r.getElementsByTagName("body")[0])&&n.style&&(t=r.createElement("div"),(i=r.createElement("div")).style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",n.appendChild(i).appendChild(t),void 0!==t.style.zoom&&(t.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",f.inlineBlockNeedsLayout=e=3===t.offsetWidth,e&&(n.style.zoom=1)),n.removeChild(i))}),function(){var e=r.createElement("div");f.deleteExpando=!0;try{delete e.test}catch(e){f.deleteExpando=!1}e=null}();var F,M=function(e){var t=d.noData[(e.nodeName+" ").toLowerCase()],n=+e.nodeType||1;return(1===n||9===n)&&(!t||!0!==t&&e.getAttribute("classid")===t)},O=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,R=/([A-Z])/g;function P(e,t,n){if(void 0===n&&1===e.nodeType){var r="data-"+t.replace(R,"-$1").toLowerCase();if("string"==typeof(n=e.getAttribute(r))){try{n="true"===n||"false"!==n&&("null"===n?null:+n+""===n?+n:O.test(n)?d.parseJSON(n):n)}catch(e){}d.data(e,t,n)}else n=void 0}return n}function B(e){var t;for(t in e)if(("data"!==t||!d.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function W(e,t,r,i){if(M(e)){var o,a,s=d.expando,u=e.nodeType,l=u?d.cache:e,c=u?e[s]:e[s]&&s;if(c&&l[c]&&(i||l[c].data)||void 0!==r||"string"!=typeof t)return c||(c=u?e[s]=n.pop()||d.guid++:s),l[c]||(l[c]=u?{}:{toJSON:d.noop}),"object"!=typeof t&&"function"!=typeof t||(i?l[c]=d.extend(l[c],t):l[c].data=d.extend(l[c].data,t)),a=l[c],i||(a.data||(a.data={}),a=a.data),void 0!==r&&(a[d.camelCase(t)]=r),"string"==typeof t?null==(o=a[t])&&(o=a[d.camelCase(t)]):o=a,o}}function I(e,t,n){if(M(e)){var r,i,o=e.nodeType,a=o?d.cache:e,s=o?e[d.expando]:d.expando;if(a[s]){if(t&&(r=n?a[s]:a[s].data)){i=(t=d.isArray(t)?t.concat(d.map(t,d.camelCase)):t in r?[t]:(t=d.camelCase(t))in r?[t]:t.split(" ")).length;for(;i--;)delete r[t[i]];if(n?!B(r):!d.isEmptyObject(r))return}(n||(delete a[s].data,B(a[s])))&&(o?d.cleanData([e],!0):f.deleteExpando||a!=a.window?delete a[s]:a[s]=void 0)}}}d.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(e){return!!(e=e.nodeType?d.cache[e[d.expando]]:e[d.expando])&&!B(e)},data:function(e,t,n){return W(e,t,n)},removeData:function(e,t){return I(e,t)},_data:function(e,t,n){return W(e,t,n,!0)},_removeData:function(e,t){return I(e,t,!0)}}),d.fn.extend({data:function(e,t){var n,r,i,o=this[0],a=o&&o.attributes;if(void 0===e){if(this.length&&(i=d.data(o),1===o.nodeType&&!d._data(o,"parsedAttrs"))){for(n=a.length;n--;)a[n]&&0===(r=a[n].name).indexOf("data-")&&P(o,r=d.camelCase(r.slice(5)),i[r]);d._data(o,"parsedAttrs",!0)}return i}return"object"==typeof e?this.each(function(){d.data(this,e)}):arguments.length>1?this.each(function(){d.data(this,e,t)}):o?P(o,e,d.data(o,e)):void 0},removeData:function(e){return this.each(function(){d.removeData(this,e)})}}),d.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=d._data(e,t),n&&(!r||d.isArray(n)?r=d._data(e,t,d.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=d.queue(e,t),r=n.length,i=n.shift(),o=d._queueHooks(e,t);"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,function(){d.dequeue(e,t)},o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return d._data(e,n)||d._data(e,n,{empty:d.Callbacks("once memory").add(function(){d._removeData(e,t+"queue"),d._removeData(e,n)})})}}),d.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length
      a",f.leadingWhitespace=3===Y.firstChild.nodeType,f.tbody=!Y.getElementsByTagName("tbody").length,f.htmlSerialize=!!Y.getElementsByTagName("link").length,f.html5Clone="<:nav>"!==r.createElement("nav").cloneNode(!0).outerHTML,G.type="checkbox",G.checked=!0,J.appendChild(G),f.appendChecked=G.checked,Y.innerHTML="",f.noCloneChecked=!!Y.cloneNode(!0).lastChild.defaultValue,J.appendChild(Y),(G=r.createElement("input")).setAttribute("type","radio"),G.setAttribute("checked","checked"),G.setAttribute("name","t"),Y.appendChild(G),f.checkClone=Y.cloneNode(!0).cloneNode(!0).lastChild.checked,f.noCloneEvent=!!Y.addEventListener,Y[d.expando]=1,f.attributes=!Y.getAttribute(d.expando);var ie={option:[1,""],legend:[1,"
      ","
      "],area:[1,"",""],param:[1,"",""],thead:[1,"","
      "],tr:[2,"","
      "],col:[2,"","
      "],td:[3,"","
      "],_default:f.htmlSerialize?[0,"",""]:[1,"X
      ","
      "]};function oe(e,t){var n,r,i=0,o=void 0!==e.getElementsByTagName?e.getElementsByTagName(t||"*"):void 0!==e.querySelectorAll?e.querySelectorAll(t||"*"):void 0;if(!o)for(o=[],n=e.childNodes||e;null!=(r=n[i]);i++)!t||d.nodeName(r,t)?o.push(r):d.merge(o,oe(r,t));return void 0===t||t&&d.nodeName(e,t)?d.merge([e],o):o}function ae(e,t){for(var n,r=0;null!=(n=e[r]);r++)d._data(n,"globalEval",!t||d._data(t[r],"globalEval"))}ie.optgroup=ie.option,ie.tbody=ie.tfoot=ie.colgroup=ie.caption=ie.thead,ie.th=ie.td;var se=/<|&#?\w+;/,ue=/
      t
      ",l.childNodes[0].style.borderCollapse="separate",(c=l.getElementsByTagName("td"))[0].style.cssText="margin:0;border:0;padding:0;display:none",(o=0===c[0].offsetHeight)&&(c[0].style.display="",c[1].style.display="none",o=0===c[0].offsetHeight)),d.removeChild(u)}l.style&&(l.style.cssText="float:left;opacity:.5",f.opacity="0.5"===l.style.opacity,f.cssFloat=!!l.style.cssFloat,l.style.backgroundClip="content-box",l.cloneNode(!0).style.backgroundClip="",f.clearCloneStyle="content-box"===l.style.backgroundClip,(u=r.createElement("div")).style.cssText="border:0;width:8px;height:0;top:0;left:-9999px;padding:0;margin-top:1px;position:absolute",l.innerHTML="",u.appendChild(l),f.boxSizing=""===l.style.boxSizing||""===l.style.MozBoxSizing||""===l.style.WebkitBoxSizing,d.extend(f,{reliableHiddenOffsets:function(){return null==t&&c(),o},boxSizingReliable:function(){return null==t&&c(),i},pixelMarginRight:function(){return null==t&&c(),n},pixelPosition:function(){return null==t&&c(),t},reliableMarginRight:function(){return null==t&&c(),a},reliableMarginLeft:function(){return null==t&&c(),s}}))}();var $e,ze,Xe=/^(top|right|bottom|left)$/;function Ue(e,t){return{get:function(){if(!e())return(this.get=t).apply(this,arguments);delete this.get}}}e.getComputedStyle?($e=function(t){var n=t.ownerDocument.defaultView;return n&&n.opener||(n=e),n.getComputedStyle(t)},ze=function(e,t,n){var r,i,o,a,s=e.style;return""!==(a=(n=n||$e(e))?n.getPropertyValue(t)||n[t]:void 0)&&void 0!==a||d.contains(e.ownerDocument,e)||(a=d.style(e,t)),n&&!f.pixelMarginRight()&&Be.test(a)&&Pe.test(t)&&(r=s.width,i=s.minWidth,o=s.maxWidth,s.minWidth=s.maxWidth=s.width=a,a=n.width,s.width=r,s.minWidth=i,s.maxWidth=o),void 0===a?a:a+""}):Ie.currentStyle&&($e=function(e){return e.currentStyle},ze=function(e,t,n){var r,i,o,a,s=e.style;return null==(a=(n=n||$e(e))?n[t]:void 0)&&s&&s[t]&&(a=s[t]),Be.test(a)&&!Xe.test(t)&&(r=s.left,(o=(i=e.runtimeStyle)&&i.left)&&(i.left=e.currentStyle.left),s.left="fontSize"===t?"1em":a,a=s.pixelLeft+"px",s.left=r,o&&(i.left=o)),void 0===a?a:a+""||"auto"});var Ve=/alpha\([^)]*\)/i,Ye=/opacity\s*=\s*([^)]*)/i,Je=/^(none|table(?!-c[ea]).+)/,Ge=new RegExp("^("+$+")(.*)$","i"),Qe={position:"absolute",visibility:"hidden",display:"block"},Ke={letterSpacing:"0",fontWeight:"400"},Ze=["Webkit","O","Moz","ms"],et=r.createElement("div").style;function tt(e){if(e in et)return e;for(var t=e.charAt(0).toUpperCase()+e.slice(1),n=Ze.length;n--;)if((e=Ze[n]+t)in et)return e}function nt(e,t){for(var n,r,i,o=[],a=0,s=e.length;a=1||""===t)&&""===d.trim(o.replace(Ve,""))&&n.removeAttribute&&(n.removeAttribute("filter"),""===t||r&&!r.filter)||(n.filter=Ve.test(o)?o.replace(Ve,i):o+" "+i)}}),d.cssHooks.marginRight=Ue(f.reliableMarginRight,function(e,t){if(t)return We(e,{display:"inline-block"},ze,[e,"marginRight"])}),d.cssHooks.marginLeft=Ue(f.reliableMarginLeft,function(e,t){if(t)return(parseFloat(ze(e,"marginLeft"))||(d.contains(e.ownerDocument,e)?e.getBoundingClientRect().left-We(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}):0))+"px"}),d.each({margin:"",padding:"",border:"Width"},function(e,t){d.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+X[r]+t]=o[r]||o[r-2]||o[0];return i}},Pe.test(e)||(d.cssHooks[e+t].set=rt)}),d.fn.extend({css:function(e,t){return Q(this,function(e,t,n){var r,i,o={},a=0;if(d.isArray(t)){for(r=$e(e),i=t.length;a1)},show:function(){return nt(this,!0)},hide:function(){return nt(this)},toggle:function(e){return"boolean"==typeof e?e?this.show():this.hide():this.each(function(){U(this)?d(this).show():d(this).hide()})}}),d.Tween=at,at.prototype={constructor:at,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||d.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(d.cssNumber[n]?"":"px")},cur:function(){var e=at.propHooks[this.prop];return e&&e.get?e.get(this):at.propHooks._default.get(this)},run:function(e){var t,n=at.propHooks[this.prop];return this.options.duration?this.pos=t=d.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):at.propHooks._default.set(this),this}},at.prototype.init.prototype=at.prototype,at.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=d.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){d.fx.step[e.prop]?d.fx.step[e.prop](e):1!==e.elem.nodeType||null==e.elem.style[d.cssProps[e.prop]]&&!d.cssHooks[e.prop]?e.elem[e.prop]=e.now:d.style(e.elem,e.prop,e.now+e.unit)}}},at.propHooks.scrollTop=at.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},d.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},d.fx=at.prototype.init,d.fx.step={};var st,ut,lt=/^(?:toggle|show|hide)$/,ct=/queueHooks$/;function ft(){return e.setTimeout(function(){st=void 0}),st=d.now()}function dt(e,t){var n,r={height:e},i=0;for(t=t?1:0;i<4;i+=2-t)r["margin"+(n=X[i])]=r["padding"+n]=e;return t&&(r.opacity=r.width=e),r}function pt(e,t,n){for(var r,i=(ht.tweeners[t]||[]).concat(ht.tweeners["*"]),o=0,a=i.length;o
      a",e=n.getElementsByTagName("a")[0],t.setAttribute("type","checkbox"),n.appendChild(t),(e=n.getElementsByTagName("a")[0]).style.cssText="top:1px",f.getSetAttribute="t"!==n.className,f.style=/top/.test(e.getAttribute("style")),f.hrefNormalized="/a"===e.getAttribute("href"),f.checkOn=!!t.value,f.optSelected=o.selected,f.enctype=!!r.createElement("form").enctype,i.disabled=!0,f.optDisabled=!o.disabled,(t=r.createElement("input")).setAttribute("value",""),f.input=""===t.getAttribute("value"),t.value="t",t.setAttribute("type","radio"),f.radioValue="t"===t.value}();var gt=/\r/g,mt=/[\x20\t\r\n\f]+/g;d.fn.extend({val:function(e){var t,n,r,i=this[0];return arguments.length?(r=d.isFunction(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,d(this).val()):e)?i="":"number"==typeof i?i+="":d.isArray(i)&&(i=d.map(i,function(e){return null==e?"":e+""})),(t=d.valHooks[this.type]||d.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))})):i?(t=d.valHooks[i.type]||d.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(gt,""):null==n?"":n:void 0}}),d.extend({valHooks:{option:{get:function(e){var t=d.find.attr(e,"value");return null!=t?t:d.trim(d.text(e)).replace(mt," ")}},select:{get:function(e){for(var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||i<0,a=o?null:[],s=o?i+1:r.length,u=i<0?s:o?i:0;u-1)try{r.selected=n=!0}catch(e){r.scrollHeight}else r.selected=!1;return n||(e.selectedIndex=-1),i}}}}),d.each(["radio","checkbox"],function(){d.valHooks[this]={set:function(e,t){if(d.isArray(t))return e.checked=d.inArray(d(e).val(),t)>-1}},f.checkOn||(d.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var vt,yt,xt=d.expr.attrHandle,bt=/^(?:checked|selected)$/i,wt=f.getSetAttribute,Tt=f.input;d.fn.extend({attr:function(e,t){return Q(this,d.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){d.removeAttr(this,e)})}}),d.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return void 0===e.getAttribute?d.prop(e,t,n):(1===o&&d.isXMLDoc(e)||(t=t.toLowerCase(),i=d.attrHooks[t]||(d.expr.match.bool.test(t)?yt:vt)),void 0!==n?null===n?void d.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=d.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!f.radioValue&&"radio"===t&&d.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(H);if(o&&1===e.nodeType)for(;n=o[i++];)r=d.propFix[n]||n,d.expr.match.bool.test(n)?Tt&&wt||!bt.test(n)?e[r]=!1:e[d.camelCase("default-"+n)]=e[r]=!1:d.attr(e,n,""),e.removeAttribute(wt?n:r)}}),yt={set:function(e,t,n){return!1===t?d.removeAttr(e,n):Tt&&wt||!bt.test(n)?e.setAttribute(!wt&&d.propFix[n]||n,n):e[d.camelCase("default-"+n)]=e[n]=!0,n}},d.each(d.expr.match.bool.source.match(/\w+/g),function(e,t){var n=xt[t]||d.find.attr;Tt&&wt||!bt.test(t)?xt[t]=function(e,t,r){var i,o;return r||(o=xt[t],xt[t]=i,i=null!=n(e,t,r)?t.toLowerCase():null,xt[t]=o),i}:xt[t]=function(e,t,n){if(!n)return e[d.camelCase("default-"+t)]?t.toLowerCase():null}}),Tt&&wt||(d.attrHooks.value={set:function(e,t,n){if(!d.nodeName(e,"input"))return vt&&vt.set(e,t,n);e.defaultValue=t}}),wt||(vt={set:function(e,t,n){var r=e.getAttributeNode(n);if(r||e.setAttributeNode(r=e.ownerDocument.createAttribute(n)),r.value=t+="","value"===n||t===e.getAttribute(n))return t}},xt.id=xt.name=xt.coords=function(e,t,n){var r;if(!n)return(r=e.getAttributeNode(t))&&""!==r.value?r.value:null},d.valHooks.button={get:function(e,t){var n=e.getAttributeNode(t);if(n&&n.specified)return n.value},set:vt.set},d.attrHooks.contenteditable={set:function(e,t,n){vt.set(e,""!==t&&t,n)}},d.each(["width","height"],function(e,t){d.attrHooks[t]={set:function(e,n){if(""===n)return e.setAttribute(t,"auto"),n}}})),f.style||(d.attrHooks.style={get:function(e){return e.style.cssText||void 0},set:function(e,t){return e.style.cssText=t+""}});var Ct=/^(?:input|select|textarea|button|object)$/i,Et=/^(?:a|area)$/i;d.fn.extend({prop:function(e,t){return Q(this,d.prop,e,t,arguments.length>1)},removeProp:function(e){return e=d.propFix[e]||e,this.each(function(){try{this[e]=void 0,delete this[e]}catch(e){}})}}),d.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&d.isXMLDoc(e)||(t=d.propFix[t]||t,i=d.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=d.find.attr(e,"tabindex");return t?parseInt(t,10):Ct.test(e.nodeName)||Et.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),f.hrefNormalized||d.each(["href","src"],function(e,t){d.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}}),f.optSelected||(d.propHooks.selected={get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),d.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){d.propFix[this.toLowerCase()]=this}),f.enctype||(d.propFix.enctype="encoding");var Nt=/[\t\r\n\f]/g;function kt(e){return d.attr(e,"class")||""}d.fn.extend({addClass:function(e){var t,n,r,i,o,a,s,u=0;if(d.isFunction(e))return this.each(function(t){d(this).addClass(e.call(this,t,kt(this)))});if("string"==typeof e&&e)for(t=e.match(H)||[];n=this[u++];)if(i=kt(n),r=1===n.nodeType&&(" "+i+" ").replace(Nt," ")){for(a=0;o=t[a++];)r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=d.trim(r))&&d.attr(n,"class",s)}return this},removeClass:function(e){var t,n,r,i,o,a,s,u=0;if(d.isFunction(e))return this.each(function(t){d(this).removeClass(e.call(this,t,kt(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof e&&e)for(t=e.match(H)||[];n=this[u++];)if(i=kt(n),r=1===n.nodeType&&(" "+i+" ").replace(Nt," ")){for(a=0;o=t[a++];)for(;r.indexOf(" "+o+" ")>-1;)r=r.replace(" "+o+" "," ");i!==(s=d.trim(r))&&d.attr(n,"class",s)}return this},toggleClass:function(e,t){var n=typeof e;return"boolean"==typeof t&&"string"===n?t?this.addClass(e):this.removeClass(e):d.isFunction(e)?this.each(function(n){d(this).toggleClass(e.call(this,n,kt(this),t),t)}):this.each(function(){var t,r,i,o;if("string"===n)for(r=0,i=d(this),o=e.match(H)||[];t=o[r++];)i.hasClass(t)?i.removeClass(t):i.addClass(t);else void 0!==e&&"boolean"!==n||((t=kt(this))&&d._data(this,"__className__",t),d.attr(this,"class",t||!1===e?"":d._data(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;for(t=" "+e+" ";n=this[r++];)if(1===n.nodeType&&(" "+kt(n)+" ").replace(Nt," ").indexOf(t)>-1)return!0;return!1}}),d.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error contextmenu".split(" "),function(e,t){d.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),d.fn.extend({hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}});var St=e.location,At=d.now(),Dt=/\?/,jt=/(,)|(\[|{)|(}|])|"(?:[^"\\\r\n]|\\["\\\/bfnrt]|\\u[\da-fA-F]{4})*"\s*:?|true|false|null|-?(?!0\d)\d+(?:\.\d+|)(?:[eE][+-]?\d+|)/g;d.parseJSON=function(t){if(e.JSON&&e.JSON.parse)return e.JSON.parse(t+"");var n,r=null,i=d.trim(t+"");return i&&!d.trim(i.replace(jt,function(e,t,i,o){return n&&t&&(r=0),0===r?e:(n=i||t,r+=!o-!i,"")}))?Function("return "+i)():d.error("Invalid JSON: "+t)},d.parseXML=function(t){var n;if(!t||"string"!=typeof t)return null;try{e.DOMParser?n=(new e.DOMParser).parseFromString(t,"text/xml"):((n=new e.ActiveXObject("Microsoft.XMLDOM")).async="false",n.loadXML(t))}catch(e){n=void 0}return n&&n.documentElement&&!n.getElementsByTagName("parsererror").length||d.error("Invalid XML: "+t),n};var Lt=/#.*$/,Ht=/([?&])_=[^&]*/,qt=/^(.*?):[ \t]*([^\r\n]*)\r?$/gm,_t=/^(?:GET|HEAD)$/,Ft=/^\/\//,Mt=/^([\w.+-]+:)(?:\/\/(?:[^\/?#]*@|)([^\/?#:]*)(?::(\d+)|)|)/,Ot={},Rt={},Pt="*/".concat("*"),Bt=St.href,Wt=Mt.exec(Bt.toLowerCase())||[];function It(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(H)||[];if(d.isFunction(n))for(;r=o[i++];)"+"===r.charAt(0)?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function $t(e,t,n,r){var i={},o=e===Rt;function a(s){var u;return i[s]=!0,d.each(e[s]||[],function(e,s){var l=s(t,n,r);return"string"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)}),u}return a(t.dataTypes[0])||!i["*"]&&a("*")}function zt(e,t){var n,r,i=d.ajaxSettings.flatOptions||{};for(r in t)void 0!==t[r]&&((i[r]?e:n||(n={}))[r]=t[r]);return n&&d.extend(!0,e,n),e}function Xt(e){return e.style&&e.style.display||d.css(e,"display")}d.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Bt,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(Wt[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Pt,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":d.parseJSON,"text xml":d.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,d.ajaxSettings),t):zt(d.ajaxSettings,e)},ajaxPrefilter:It(Ot),ajaxTransport:It(Rt),ajax:function(t,n){"object"==typeof t&&(n=t,t=void 0),n=n||{};var r,i,o,a,s,u,l,c,f=d.ajaxSetup({},n),p=f.context||f,h=f.context&&(p.nodeType||p.jquery)?d(p):d.event,g=d.Deferred(),m=d.Callbacks("once memory"),v=f.statusCode||{},y={},x={},b=0,w="canceled",T={readyState:0,getResponseHeader:function(e){var t;if(2===b){if(!c)for(c={};t=qt.exec(a);)c[t[1].toLowerCase()]=t[2];t=c[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return 2===b?a:null},setRequestHeader:function(e,t){var n=e.toLowerCase();return b||(e=x[n]=x[n]||e,y[e]=t),this},overrideMimeType:function(e){return b||(f.mimeType=e),this},statusCode:function(e){var t;if(e)if(b<2)for(t in e)v[t]=[v[t],e[t]];else T.always(e[T.status]);return this},abort:function(e){var t=e||w;return l&&l.abort(t),C(0,t),this}};if(g.promise(T).complete=m.add,T.success=T.done,T.error=T.fail,f.url=((t||f.url||Bt)+"").replace(Lt,"").replace(Ft,Wt[1]+"//"),f.type=n.method||n.type||f.method||f.type,f.dataTypes=d.trim(f.dataType||"*").toLowerCase().match(H)||[""],null==f.crossDomain&&(r=Mt.exec(f.url.toLowerCase()),f.crossDomain=!(!r||r[1]===Wt[1]&&r[2]===Wt[2]&&(r[3]||("http:"===r[1]?"80":"443"))===(Wt[3]||("http:"===Wt[1]?"80":"443")))),f.data&&f.processData&&"string"!=typeof f.data&&(f.data=d.param(f.data,f.traditional)),$t(Ot,f,n,T),2===b)return T;for(i in(u=d.event&&f.global)&&0==d.active++&&d.event.trigger("ajaxStart"),f.type=f.type.toUpperCase(),f.hasContent=!_t.test(f.type),o=f.url,f.hasContent||(f.data&&(o=f.url+=(Dt.test(o)?"&":"?")+f.data,delete f.data),!1===f.cache&&(f.url=Ht.test(o)?o.replace(Ht,"$1_="+At++):o+(Dt.test(o)?"&":"?")+"_="+At++)),f.ifModified&&(d.lastModified[o]&&T.setRequestHeader("If-Modified-Since",d.lastModified[o]),d.etag[o]&&T.setRequestHeader("If-None-Match",d.etag[o])),(f.data&&f.hasContent&&!1!==f.contentType||n.contentType)&&T.setRequestHeader("Content-Type",f.contentType),T.setRequestHeader("Accept",f.dataTypes[0]&&f.accepts[f.dataTypes[0]]?f.accepts[f.dataTypes[0]]+("*"!==f.dataTypes[0]?", "+Pt+"; q=0.01":""):f.accepts["*"]),f.headers)T.setRequestHeader(i,f.headers[i]);if(f.beforeSend&&(!1===f.beforeSend.call(p,T,f)||2===b))return T.abort();for(i in w="abort",{success:1,error:1,complete:1})T[i](f[i]);if(l=$t(Rt,f,n,T)){if(T.readyState=1,u&&h.trigger("ajaxSend",[T,f]),2===b)return T;f.async&&f.timeout>0&&(s=e.setTimeout(function(){T.abort("timeout")},f.timeout));try{b=1,l.send(y,C)}catch(e){if(!(b<2))throw e;C(-1,e)}}else C(-1,"No Transport");function C(t,n,r,i){var c,y,x,w,C,E=n;2!==b&&(b=2,s&&e.clearTimeout(s),l=void 0,a=i||"",T.readyState=t>0?4:0,c=t>=200&&t<300||304===t,r&&(w=function(e,t,n){for(var r,i,o,a,s=e.contents,u=e.dataTypes;"*"===u[0];)u.shift(),void 0===i&&(i=e.mimeType||t.getResponseHeader("Content-Type"));if(i)for(a in s)if(s[a]&&s[a].test(i)){u.unshift(a);break}if(u[0]in n)o=u[0];else{for(a in n){if(!u[0]||e.converters[a+" "+u[0]]){o=a;break}r||(r=a)}o=o||r}if(o)return o!==u[0]&&u.unshift(o),n[o]}(f,T,r)),w=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];for(o=c.shift();o;)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e.throws)t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}(f,w,T,c),c?(f.ifModified&&((C=T.getResponseHeader("Last-Modified"))&&(d.lastModified[o]=C),(C=T.getResponseHeader("etag"))&&(d.etag[o]=C)),204===t||"HEAD"===f.type?E="nocontent":304===t?E="notmodified":(E=w.state,y=w.data,c=!(x=w.error))):(x=E,!t&&E||(E="error",t<0&&(t=0))),T.status=t,T.statusText=(n||E)+"",c?g.resolveWith(p,[y,E,T]):g.rejectWith(p,[T,E,x]),T.statusCode(v),v=void 0,u&&h.trigger(c?"ajaxSuccess":"ajaxError",[T,f,c?y:x]),m.fireWith(p,[T,E]),u&&(h.trigger("ajaxComplete",[T,f]),--d.active||d.event.trigger("ajaxStop")))}return T},getJSON:function(e,t,n){return d.get(e,t,n,"json")},getScript:function(e,t){return d.get(e,void 0,t,"script")}}),d.each(["get","post"],function(e,t){d[t]=function(e,n,r,i){return d.isFunction(n)&&(i=i||r,r=n,n=void 0),d.ajax(d.extend({url:e,type:t,dataType:i,data:n,success:r},d.isPlainObject(e)&&e))}}),d._evalUrl=function(e){return d.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,throws:!0})},d.fn.extend({wrapAll:function(e){if(d.isFunction(e))return this.each(function(t){d(this).wrapAll(e.call(this,t))});if(this[0]){var t=d(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){for(var e=this;e.firstChild&&1===e.firstChild.nodeType;)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return d.isFunction(e)?this.each(function(t){d(this).wrapInner(e.call(this,t))}):this.each(function(){var t=d(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=d.isFunction(e);return this.each(function(n){d(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){d.nodeName(this,"body")||d(this).replaceWith(this.childNodes)}).end()}}),d.expr.filters.hidden=function(e){return f.reliableHiddenOffsets()?e.offsetWidth<=0&&e.offsetHeight<=0&&!e.getClientRects().length:function(e){if(!d.contains(e.ownerDocument||r,e))return!0;for(;e&&1===e.nodeType;){if("none"===Xt(e)||"hidden"===e.type)return!0;e=e.parentNode}return!1}(e)},d.expr.filters.visible=function(e){return!d.expr.filters.hidden(e)};var Ut=/%20/g,Vt=/\[\]$/,Yt=/\r?\n/g,Jt=/^(?:submit|button|image|reset|file)$/i,Gt=/^(?:input|select|textarea|keygen)/i;function Qt(e,t,n,r){var i;if(d.isArray(t))d.each(t,function(t,i){n||Vt.test(e)?r(e,i):Qt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==d.type(t))r(e,t);else for(i in t)Qt(e+"["+i+"]",t[i],n,r)}d.param=function(e,t){var n,r=[],i=function(e,t){t=d.isFunction(t)?t():null==t?"":t,r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(t)};if(void 0===t&&(t=d.ajaxSettings&&d.ajaxSettings.traditional),d.isArray(e)||e.jquery&&!d.isPlainObject(e))d.each(e,function(){i(this.name,this.value)});else for(n in e)Qt(n,e[n],t,i);return r.join("&").replace(Ut,"+")},d.fn.extend({serialize:function(){return d.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=d.prop(this,"elements");return e?d.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!d(this).is(":disabled")&&Gt.test(this.nodeName)&&!Jt.test(e)&&(this.checked||!K.test(e))}).map(function(e,t){var n=d(this).val();return null==n?null:d.isArray(n)?d.map(n,function(e){return{name:t.name,value:e.replace(Yt,"\r\n")}}):{name:t.name,value:n.replace(Yt,"\r\n")}}).get()}}),d.ajaxSettings.xhr=void 0!==e.ActiveXObject?function(){return this.isLocal?nn():r.documentMode>8?tn():/^(get|post|head|put|delete|options)$/i.test(this.type)&&tn()||nn()}:tn;var Kt=0,Zt={},en=d.ajaxSettings.xhr();function tn(){try{return new e.XMLHttpRequest}catch(e){}}function nn(){try{return new e.ActiveXObject("Microsoft.XMLHTTP")}catch(e){}}e.attachEvent&&e.attachEvent("onunload",function(){for(var e in Zt)Zt[e](void 0,!0)}),f.cors=!!en&&"withCredentials"in en,(en=f.ajax=!!en)&&d.ajaxTransport(function(t){var n;if(!t.crossDomain||f.cors)return{send:function(r,i){var o,a=t.xhr(),s=++Kt;if(a.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(o in t.xhrFields)a[o]=t.xhrFields[o];for(o in t.mimeType&&a.overrideMimeType&&a.overrideMimeType(t.mimeType),t.crossDomain||r["X-Requested-With"]||(r["X-Requested-With"]="XMLHttpRequest"),r)void 0!==r[o]&&a.setRequestHeader(o,r[o]+"");a.send(t.hasContent&&t.data||null),n=function(e,r){var o,u,l;if(n&&(r||4===a.readyState))if(delete Zt[s],n=void 0,a.onreadystatechange=d.noop,r)4!==a.readyState&&a.abort();else{l={},o=a.status,"string"==typeof a.responseText&&(l.text=a.responseText);try{u=a.statusText}catch(e){u=""}o||!t.isLocal||t.crossDomain?1223===o&&(o=204):o=l.text?200:404}l&&i(o,u,l,a.getAllResponseHeaders())},t.async?4===a.readyState?e.setTimeout(n):a.onreadystatechange=Zt[s]=n:n()},abort:function(){n&&n(void 0,!0)}}}),d.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return d.globalEval(e),e}}}),d.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),d.ajaxTransport("script",function(e){if(e.crossDomain){var t,n=r.head||d("head")[0]||r.documentElement;return{send:function(i,o){(t=r.createElement("script")).async=!0,e.scriptCharset&&(t.charset=e.scriptCharset),t.src=e.url,t.onload=t.onreadystatechange=function(e,n){(n||!t.readyState||/loaded|complete/.test(t.readyState))&&(t.onload=t.onreadystatechange=null,t.parentNode&&t.parentNode.removeChild(t),t=null,n||o(200,"success"))},n.insertBefore(t,n.firstChild)},abort:function(){t&&t.onload(void 0,!0)}}}});var rn=[],on=/(=)\?(?=&|$)|\?\?/;d.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=rn.pop()||d.expando+"_"+At++;return this[e]=!0,e}}),d.ajaxPrefilter("json jsonp",function(t,n,r){var i,o,a,s=!1!==t.jsonp&&(on.test(t.url)?"url":"string"==typeof t.data&&0===(t.contentType||"").indexOf("application/x-www-form-urlencoded")&&on.test(t.data)&&"data");if(s||"jsonp"===t.dataTypes[0])return i=t.jsonpCallback=d.isFunction(t.jsonpCallback)?t.jsonpCallback():t.jsonpCallback,s?t[s]=t[s].replace(on,"$1"+i):!1!==t.jsonp&&(t.url+=(Dt.test(t.url)?"&":"?")+t.jsonp+"="+i),t.converters["script json"]=function(){return a||d.error(i+" was not called"),a[0]},t.dataTypes[0]="json",o=e[i],e[i]=function(){a=arguments},r.always(function(){void 0===o?d(e).removeProp(i):e[i]=o,t[i]&&(t.jsonpCallback=n.jsonpCallback,rn.push(i)),a&&d.isFunction(o)&&o(a[0]),a=o=void 0}),"script"}),d.parseHTML=function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||r;var i=T.exec(e),o=!n&&[];return i?[t.createElement(i[1])]:(i=ce([e],t,o),o&&o.length&&d(o).remove(),d.merge([],i.childNodes))};var an=d.fn.load;function sn(e){return d.isWindow(e)?e:9===e.nodeType&&(e.defaultView||e.parentWindow)}d.fn.load=function(e,t,n){if("string"!=typeof e&&an)return an.apply(this,arguments);var r,i,o,a=this,s=e.indexOf(" ");return s>-1&&(r=d.trim(e.slice(s,e.length)),e=e.slice(0,s)),d.isFunction(t)?(n=t,t=void 0):t&&"object"==typeof t&&(i="POST"),a.length>0&&d.ajax({url:e,type:i||"GET",dataType:"html",data:t}).done(function(e){o=arguments,a.html(r?d("
      ").append(d.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},d.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){d.fn[t]=function(e){return this.on(t,e)}}),d.expr.filters.animated=function(e){return d.grep(d.timers,function(t){return e===t.elem}).length},d.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=d.css(e,"position"),c=d(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=d.css(e,"top"),u=d.css(e,"left"),("absolute"===l||"fixed"===l)&&d.inArray("auto",[o,u])>-1?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),d.isFunction(t)&&(t=t.call(e,n,d.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},d.fn.extend({offset:function(e){if(arguments.length)return void 0===e?this:this.each(function(t){d.offset.setOffset(this,e,t)});var t,n,r={top:0,left:0},i=this[0],o=i&&i.ownerDocument;return o?(t=o.documentElement,d.contains(t,i)?(void 0!==i.getBoundingClientRect&&(r=i.getBoundingClientRect()),n=sn(o),{top:r.top+(n.pageYOffset||t.scrollTop)-(t.clientTop||0),left:r.left+(n.pageXOffset||t.scrollLeft)-(t.clientLeft||0)}):r):void 0},position:function(){if(this[0]){var e,t,n={top:0,left:0},r=this[0];return"fixed"===d.css(r,"position")?t=r.getBoundingClientRect():(e=this.offsetParent(),t=this.offset(),d.nodeName(e[0],"html")||(n=e.offset()),n.top+=d.css(e[0],"borderTopWidth",!0),n.left+=d.css(e[0],"borderLeftWidth",!0)),{top:t.top-n.top-d.css(r,"marginTop",!0),left:t.left-n.left-d.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent;e&&!d.nodeName(e,"html")&&"static"===d.css(e,"position");)e=e.offsetParent;return e||Ie})}}),d.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,t){var n=/Y/.test(t);d.fn[e]=function(r){return Q(this,function(e,r,i){var o=sn(e);if(void 0===i)return o?t in o?o[t]:o.document.documentElement[r]:e[r];o?o.scrollTo(n?d(o).scrollLeft():i,n?i:d(o).scrollTop()):e[r]=i},e,r,arguments.length,null)}}),d.each(["top","left"],function(e,t){d.cssHooks[t]=Ue(f.pixelPosition,function(e,n){if(n)return n=ze(e,t),Be.test(n)?d(e).position()[t]+"px":n})}),d.each({Height:"height",Width:"width"},function(e,t){d.each({padding:"inner"+e,content:t,"":"outer"+e},function(n,r){d.fn[r]=function(r,i){var o=arguments.length&&(n||"boolean"!=typeof r),a=n||(!0===r||!0===i?"margin":"border");return Q(this,function(t,n,r){var i;return d.isWindow(t)?t.document.documentElement["client"+e]:9===t.nodeType?(i=t.documentElement,Math.max(t.body["scroll"+e],i["scroll"+e],t.body["offset"+e],i["offset"+e],i["client"+e])):void 0===r?d.css(t,n,a):d.style(t,n,r,a)},t,o?r:void 0,o,null)}})}),d.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)}}),d.fn.size=function(){return this.length},d.fn.andSelf=d.fn.addBack,"function"==typeof define&&define.amd&&define("jquery",[],function(){return d});var un=e.jQuery,ln=e.$;return d.noConflict=function(t){return e.$===d&&(e.$=ln),t&&e.jQuery===d&&(e.jQuery=un),d},t||(e.jQuery=e.$=d),d}); + +// Snap this specific version of jQuery into H5P. jQuery.noConflict will +// revert the globals to what they were before this file was loaded. +var H5P = window.H5P = window.H5P || {}; + +H5P.jQuery = jQuery.noConflict(true); +H5P.jQuery.ajaxPrefilter(function (s) { + if (s.crossDomain) { + s.contents.script = false; + } +}); diff --git a/src/core/features/h5p/assets/js/request-queue.js b/src/core/features/h5p/assets/js/request-queue.js new file mode 100644 index 000000000..5b367e147 --- /dev/null +++ b/src/core/features/h5p/assets/js/request-queue.js @@ -0,0 +1,436 @@ +/** + * Queue requests and handle them at your convenience + * + * @type {RequestQueue} + */ +H5P.RequestQueue = (function ($, EventDispatcher) { + /** + * A queue for requests, will be automatically processed when regaining connection + * + * @param {boolean} [options.showToast] Show toast when losing or regaining connection + * @constructor + */ + const RequestQueue = function (options) { + EventDispatcher.call(this); + this.processingQueue = false; + options = options || {}; + + this.showToast = options.showToast; + this.itemName = 'requestQueue'; + }; + + /** + * Add request to queue. Only supports posts currently. + * + * @param {string} url + * @param {Object} data + * @returns {boolean} + */ + RequestQueue.prototype.add = function (url, data) { + if (!window.localStorage) { + return false; + } + + let storedStatements = this.getStoredRequests(); + if (!storedStatements) { + storedStatements = []; + } + + storedStatements.push({ + url: url, + data: data, + }); + + window.localStorage.setItem(this.itemName, JSON.stringify(storedStatements)); + + this.trigger('requestQueued', { + storedStatements: storedStatements, + processingQueue: this.processingQueue, + }); + return true; + }; + + /** + * Get stored requests + * + * @returns {boolean|Array} Stored requests + */ + RequestQueue.prototype.getStoredRequests = function () { + if (!window.localStorage) { + return false; + } + + const item = window.localStorage.getItem(this.itemName); + if (!item) { + return []; + } + + return JSON.parse(item); + }; + + /** + * Clear stored requests + * + * @returns {boolean} True if the storage was successfully cleared + */ + RequestQueue.prototype.clearQueue = function () { + if (!window.localStorage) { + return false; + } + + window.localStorage.removeItem(this.itemName); + return true; + }; + + /** + * Start processing of requests queue + * + * @return {boolean} Returns false if it was not possible to resume processing queue + */ + RequestQueue.prototype.resumeQueue = function () { + // Not supported + if (!H5PIntegration || !window.navigator || !window.localStorage) { + return false; + } + + // Already processing + if (this.processingQueue) { + return false; + } + + // Attempt to send queued requests + const queue = this.getStoredRequests(); + const queueLength = queue.length; + + // Clear storage, failed requests will be re-added + this.clearQueue(); + + // No items left in queue + if (!queueLength) { + this.trigger('emptiedQueue', queue); + return true; + } + + // Make sure requests are not changed while they're being handled + this.processingQueue = true; + + // Process queue in original order + this.processQueue(queue); + return true + }; + + /** + * Process first item in the request queue + * + * @param {Array} queue Request queue + */ + RequestQueue.prototype.processQueue = function (queue) { + if (!queue.length) { + return; + } + + this.trigger('processingQueue'); + + // Make sure the requests are processed in a FIFO order + const request = queue.shift(); + + const self = this; + $.post(request.url, request.data) + .fail(self.onQueuedRequestFail.bind(self, request)) + .always(self.onQueuedRequestProcessed.bind(self, queue)) + }; + + /** + * Request fail handler + * + * @param {Object} request + */ + RequestQueue.prototype.onQueuedRequestFail = function (request) { + // Queue the failed request again if we're offline + if (!window.navigator.onLine) { + this.add(request.url, request.data); + } + }; + + /** + * An item in the queue was processed + * + * @param {Array} queue Queue that was processed + */ + RequestQueue.prototype.onQueuedRequestProcessed = function (queue) { + if (queue.length) { + this.processQueue(queue); + return; + } + + // Finished processing this queue + this.processingQueue = false; + + // Run empty queue callback with next request queue + const requestQueue = this.getStoredRequests(); + this.trigger('queueEmptied', requestQueue); + }; + + /** + * Display toast message on the first content of current page + * + * @param {string} msg Message to display + * @param {boolean} [forceShow] Force override showing the toast + * @param {Object} [configOverride] Override toast message config + */ + RequestQueue.prototype.displayToastMessage = function (msg, forceShow, configOverride) { + if (!this.showToast && !forceShow) { + return; + } + + const config = H5P.jQuery.extend(true, {}, { + position: { + horizontal : 'centered', + vertical: 'centered', + noOverflowX: true, + } + }, configOverride); + + H5P.attachToastTo(H5P.jQuery('.h5p-content:first')[0], msg, config); + }; + + return RequestQueue; +})(H5P.jQuery, H5P.EventDispatcher); + +/** + * Request queue for retrying failing requests, will automatically retry them when you come online + * + * @type {offlineRequestQueue} + */ +H5P.OfflineRequestQueue = (function (RequestQueue, Dialog) { + + /** + * Constructor + * + * @param {Object} [options] Options for offline request queue + * @param {Object} [options.instance] The H5P instance which UI components are placed within + */ + const offlineRequestQueue = function (options) { + const requestQueue = new RequestQueue(); + + // We could handle requests from previous pages here, but instead we throw them away + requestQueue.clearQueue(); + + let startTime = null; + const retryIntervals = [10, 20, 40, 60, 120, 300, 600]; + let intervalIndex = -1; + let currentInterval = null; + let isAttached = false; + let isShowing = false; + let isLoading = false; + const instance = options.instance; + + const offlineDialog = new Dialog({ + headerText: H5P.t('offlineDialogHeader'), + dialogText: H5P.t('offlineDialogBody'), + confirmText: H5P.t('offlineDialogRetryButtonLabel'), + hideCancel: true, + hideExit: true, + classes: ['offline'], + instance: instance, + skipRestoreFocus: true, + }); + + const dialog = offlineDialog.getElement(); + + // Add retry text to body + const countDownText = document.createElement('div'); + countDownText.classList.add('count-down'); + countDownText.innerHTML = H5P.t('offlineDialogRetryMessage') + .replace(':num', '0'); + + dialog.querySelector('.h5p-confirmation-dialog-text').appendChild(countDownText); + const countDownNum = countDownText.querySelector('.count-down-num'); + + // Create throbber + const throbberWrapper = document.createElement('div'); + throbberWrapper.classList.add('throbber-wrapper'); + const throbber = document.createElement('div'); + throbber.classList.add('sending-requests-throbber'); + throbberWrapper.appendChild(throbber); + + requestQueue.on('requestQueued', function (e) { + // Already processing queue, wait until queue has finished processing before showing dialog + if (e.data && e.data.processingQueue) { + return; + } + + if (!isAttached) { + const rootContent = document.body.querySelector('.h5p-content'); + if (!rootContent) { + return; + } + offlineDialog.appendTo(rootContent); + rootContent.appendChild(throbberWrapper); + isAttached = true; + } + + startCountDown(); + }.bind(this)); + + requestQueue.on('queueEmptied', function (e) { + if (e.data && e.data.length) { + // New requests were added while processing queue or requests failed again. Re-queue requests. + startCountDown(true); + return; + } + + // Successfully emptied queue + clearInterval(currentInterval); + toggleThrobber(false); + intervalIndex = -1; + if (isShowing) { + offlineDialog.hide(); + isShowing = false; + } + + requestQueue.displayToastMessage( + H5P.t('offlineSuccessfulSubmit'), + true, + { + position: { + vertical: 'top', + offsetVertical: '100', + } + } + ); + + }.bind(this)); + + offlineDialog.on('confirmed', function () { + // Show dialog on next render in case it is being hidden by the 'confirm' button + isShowing = false; + setTimeout(function () { + retryRequests(); + }, 100); + }.bind(this)); + + // Initialize listener for when requests are added to queue + window.addEventListener('online', function () { + retryRequests(); + }.bind(this)); + + // Listen for queued requests outside the iframe + window.addEventListener('message', function (event) { + const isValidQueueEvent = window.parent === event.source + && event.data.context === 'h5p' + && event.data.action === 'queueRequest'; + + if (!isValidQueueEvent) { + return; + } + + this.add(event.data.url, event.data.data); + }.bind(this)); + + /** + * Toggle throbber visibility + * + * @param {boolean} [forceShow] Will force throbber visibility if set + */ + const toggleThrobber = function (forceShow) { + isLoading = !isLoading; + if (forceShow !== undefined) { + isLoading = forceShow; + } + + if (isLoading && isShowing) { + offlineDialog.hide(); + isShowing = false; + } + + if (isLoading) { + throbberWrapper.classList.add('show'); + } + else { + throbberWrapper.classList.remove('show'); + } + }; + /** + * Retries the failed requests + */ + const retryRequests = function () { + clearInterval(currentInterval); + toggleThrobber(true); + requestQueue.resumeQueue(); + }; + + /** + * Increments retry interval + */ + const incrementRetryInterval = function () { + intervalIndex += 1; + if (intervalIndex >= retryIntervals.length) { + intervalIndex = retryIntervals.length - 1; + } + }; + + /** + * Starts counting down to retrying queued requests. + * + * @param forceDelayedShow + */ + const startCountDown = function (forceDelayedShow) { + // Already showing, wait for retry + if (isShowing) { + return; + } + + toggleThrobber(false); + if (!isShowing) { + if (forceDelayedShow) { + // Must force delayed show since dialog may be hiding, and confirmation dialog does not + // support this. + setTimeout(function () { + offlineDialog.show(0); + }, 100); + } + else { + offlineDialog.show(0); + } + } + isShowing = true; + startTime = new Date().getTime(); + incrementRetryInterval(); + clearInterval(currentInterval); + currentInterval = setInterval(updateCountDown, 100); + }; + + /** + * Updates the count down timer. Retries requests when time expires. + */ + const updateCountDown = function () { + const time = new Date().getTime(); + const timeElapsed = Math.floor((time - startTime) / 1000); + const timeLeft = retryIntervals[intervalIndex] - timeElapsed; + countDownNum.textContent = timeLeft.toString(); + + // Retry interval reached, retry requests + if (timeLeft <= 0) { + retryRequests(); + } + }; + + /** + * Add request to offline request queue. Only supports posts for now. + * + * @param {string} url The request url + * @param {Object} data The request data + */ + this.add = function (url, data) { + // Only queue request if it failed because we are offline + if (window.navigator.onLine) { + return false; + } + + requestQueue.add(url, data); + }; + }; + + return offlineRequestQueue; +})(H5P.RequestQueue, H5P.ConfirmationDialog); diff --git a/src/core/features/h5p/assets/js/settings/h5p-disable-hub.js b/src/core/features/h5p/assets/js/settings/h5p-disable-hub.js new file mode 100644 index 000000000..406e8b2d1 --- /dev/null +++ b/src/core/features/h5p/assets/js/settings/h5p-disable-hub.js @@ -0,0 +1,68 @@ +/* global H5PDisableHubData */ + +/** + * Global data for disable hub functionality + * + * @typedef {object} H5PDisableHubData Data passed in from the backend + * + * @property {string} selector Selector for the disable hub check-button + * @property {string} overlaySelector Selector for the element that the confirmation dialog will mask + * @property {Array} errors Errors found with the current server setup + * + * @property {string} header Header of the confirmation dialog + * @property {string} confirmationDialogMsg Body of the confirmation dialog + * @property {string} cancelLabel Cancel label of the confirmation dialog + * @property {string} confirmLabel Confirm button label of the confirmation dialog + * + */ +/** + * Utility that makes it possible to force the user to confirm that he really + * wants to use the H5P hub without proper server settings. + */ +(function ($) { + + $(document).on('ready', function () { + + // No data found + if (!H5PDisableHubData) { + return; + } + + // No errors found, no need for confirmation dialog + if (!H5PDisableHubData.errors || !H5PDisableHubData.errors.length) { + return; + } + + H5PDisableHubData.selector = H5PDisableHubData.selector || + '.h5p-settings-disable-hub-checkbox'; + H5PDisableHubData.overlaySelector = H5PDisableHubData.overlaySelector || + '.h5p-settings-container'; + + var dialogHtml = '
      ' + + '

      ' + H5PDisableHubData.errors.join('

      ') + '

      ' + + '

      ' + H5PDisableHubData.confirmationDialogMsg + '

      '; + + // Create confirmation dialog, make sure to include translations + var confirmationDialog = new H5P.ConfirmationDialog({ + headerText: H5PDisableHubData.header, + dialogText: dialogHtml, + cancelText: H5PDisableHubData.cancelLabel, + confirmText: H5PDisableHubData.confirmLabel + }).appendTo($(H5PDisableHubData.overlaySelector).get(0)); + + confirmationDialog.on('confirmed', function () { + enableButton.get(0).checked = true; + }); + + confirmationDialog.on('canceled', function () { + enableButton.get(0).checked = false; + }); + + var enableButton = $(H5PDisableHubData.selector); + enableButton.change(function () { + if ($(this).is(':checked')) { + confirmationDialog.show(enableButton.offset().top); + } + }); + }); +})(H5P.jQuery); diff --git a/src/core/features/h5p/assets/moodle/js/embed.js b/src/core/features/h5p/assets/moodle/js/embed.js new file mode 100644 index 000000000..904eded89 --- /dev/null +++ b/src/core/features/h5p/assets/moodle/js/embed.js @@ -0,0 +1,203 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/* global H5PEmbedCommunicator:true */ +/** + * When embedded the communicator helps talk to the parent page. + * This is a copy of the H5P.communicator, which we need to communicate in this context + * + * @type {H5PEmbedCommunicator} + * @module core_h5p + * @copyright 2019 Joubel AS + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +H5PEmbedCommunicator = (function() { + /** + * @class + * @private + */ + function Communicator() { + var self = this; + + // Maps actions to functions. + var actionHandlers = {}; + + // Register message listener. + window.addEventListener('message', function receiveMessage(event) { + if (window.parent !== event.source || event.data.context !== 'h5p') { + return; // Only handle messages from parent and in the correct context. + } + + if (actionHandlers[event.data.action] !== undefined) { + actionHandlers[event.data.action](event.data); + } + }, false); + + /** + * Register action listener. + * + * @param {string} action What you are waiting for + * @param {function} handler What you want done + */ + self.on = function(action, handler) { + actionHandlers[action] = handler; + }; + + /** + * Send a message to the all mighty father. + * + * @param {string} action + * @param {Object} [data] payload + */ + self.send = function(action, data) { + if (data === undefined) { + data = {}; + } + data.context = 'h5p'; + data.action = action; + + // Parent origin can be anything. + window.parent.postMessage(data, '*'); + }; + + /** + * Send a xAPI statement to LMS. + * + * @param {string} component + * @param {Object} statements + */ + self.post = function(component, statements) { + window.parent.postMessage({ + environment: 'moodleapp', + context: 'h5p', + action: 'xapi_post_statement', + component: component, + statements: statements, + }, '*'); + }; + } + + return (window.postMessage && window.addEventListener ? new Communicator() : undefined); +})(); + +document.onreadystatechange = function() { + // Wait for instances to be initialize. + if (document.readyState !== 'complete') { + return; + } + + // Check for H5P iFrame. + var iFrame = document.querySelector('.h5p-iframe'); + if (!iFrame || !iFrame.contentWindow) { + return; + } + var H5P = iFrame.contentWindow.H5P; + + // Check for H5P instances. + if (!H5P || !H5P.instances || !H5P.instances[0]) { + return; + } + + var resizeDelay; + var instance = H5P.instances[0]; + var parentIsFriendly = false; + + // Handle that the resizer is loaded after the iframe. + H5PEmbedCommunicator.on('ready', function() { + H5PEmbedCommunicator.send('hello'); + }); + + // Handle hello message from our parent window. + H5PEmbedCommunicator.on('hello', function() { + // Initial setup/handshake is done. + parentIsFriendly = true; + + // Hide scrollbars for correct size. + iFrame.contentDocument.body.style.overflow = 'hidden'; + + document.body.classList.add('h5p-resizing'); + + // Content need to be resized to fit the new iframe size. + H5P.trigger(instance, 'resize'); + }); + + // When resize has been prepared tell parent window to resize. + H5PEmbedCommunicator.on('resizePrepared', function() { + H5PEmbedCommunicator.send('resize', { + scrollHeight: iFrame.contentDocument.body.scrollHeight + }); + }); + + H5PEmbedCommunicator.on('resize', function() { + H5P.trigger(instance, 'resize'); + }); + + H5P.on(instance, 'resize', function() { + if (H5P.isFullscreen) { + return; // Skip iframe resize. + } + + // Use a delay to make sure iframe is resized to the correct size. + clearTimeout(resizeDelay); + resizeDelay = setTimeout(function() { + // Only resize if the iframe can be resized. + if (parentIsFriendly) { + H5PEmbedCommunicator.send('prepareResize', + { + scrollHeight: iFrame.contentDocument.body.scrollHeight, + clientHeight: iFrame.contentDocument.body.clientHeight + } + ); + } else { + H5PEmbedCommunicator.send('hello'); + } + }, 0); + }); + + // Get emitted xAPI data. + H5P.externalDispatcher.on('xAPI', function(event) { + var moodlecomponent = H5P.getMoodleComponent(); + if (moodlecomponent == undefined) { + return; + } + // Skip malformed events. + var hasStatement = event && event.data && event.data.statement; + if (!hasStatement) { + return; + } + + var statement = event.data.statement; + var validVerb = statement.verb && statement.verb.id; + if (!validVerb) { + return; + } + + var isCompleted = statement.verb.id === 'http://adlnet.gov/expapi/verbs/answered' + || statement.verb.id === 'http://adlnet.gov/expapi/verbs/completed'; + + var isChild = statement.context && statement.context.contextActivities && + statement.context.contextActivities.parent && + statement.context.contextActivities.parent[0] && + statement.context.contextActivities.parent[0].id; + + if (isCompleted && !isChild) { + var statements = H5P.getXAPIStatements(this.contentId, statement); + H5PEmbedCommunicator.post(moodlecomponent, statements); + } + }); + + // Trigger initial resize for instance. + H5P.trigger(instance, 'resize'); +}; diff --git a/src/core/features/h5p/assets/moodle/js/h5p_overrides.js b/src/core/features/h5p/assets/moodle/js/h5p_overrides.js new file mode 100644 index 000000000..c9350e204 --- /dev/null +++ b/src/core/features/h5p/assets/moodle/js/h5p_overrides.js @@ -0,0 +1,55 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +H5P.findInstanceFromId = function (contentId) { + if (!contentId) { + return H5P.instances[0]; + } + if (H5P.instances !== undefined) { + for (var i = 0; i < H5P.instances.length; i++) { + if (H5P.instances[i].contentId === contentId) { + return H5P.instances[i]; + } + } + } + return undefined; +}; +H5P.getXAPIStatements = function (contentId, statement) { + var statements = []; + var instance = H5P.findInstanceFromId(contentId); + if (!instance){ + return statements; + } + if (instance.getXAPIData == undefined) { + var xAPIData = { + statement: statement + }; + } else { + var xAPIData = instance.getXAPIData(); + } + if (xAPIData.statement != undefined) { + statements.push(xAPIData.statement); + } + if (xAPIData.children != undefined) { + statements = statements.concat(xAPIData.children.map(a => a.statement)); + } + return statements; +}; +H5P.getMoodleComponent = function () { + if (H5PIntegration.moodleComponent) { + return H5PIntegration.moodleComponent; + } + return undefined; +}; \ No newline at end of file diff --git a/src/core/features/h5p/assets/moodle/js/params.js b/src/core/features/h5p/assets/moodle/js/params.js new file mode 100644 index 000000000..87722aacd --- /dev/null +++ b/src/core/features/h5p/assets/moodle/js/params.js @@ -0,0 +1,46 @@ +// (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. + +/** + * Handle params included in the URL and put them in the H5PIntegration object if it exists. + */ + +if (window.H5PIntegration && window.H5PIntegration.contents && location.search) { + var contentData = window.H5PIntegration.contents[Object.keys(window.H5PIntegration.contents)[0]]; + + var search = location.search.replace(/^\?/, ''); + var split = search.split('&'); + + split.forEach(function(param) { + var nameAndValue = param.split('='); + + if (nameAndValue[0] == 'displayOptions' && contentData) { + try { + contentData.displayOptions = contentData.displayOptions || {}; + + var displayOptions = JSON.parse(decodeURIComponent(nameAndValue[1])); + + if (displayOptions && typeof displayOptions == 'object') { + Object.assign(contentData.displayOptions, displayOptions); + } + } catch (error) { + console.error('Error parsing display options', decodeURIComponent(nameAndValue[1])); + } + } else if (nameAndValue[0] == 'component') { + window.H5PIntegration.moodleComponent = nameAndValue[1]; + } else if (nameAndValue[0] == 'trackingUrl' && contentData) { + contentData.url = nameAndValue[1]; + } + }); +} diff --git a/src/core/features/h5p/assets/styles/h5p-admin.css b/src/core/features/h5p/assets/styles/h5p-admin.css new file mode 100644 index 000000000..68306126f --- /dev/null +++ b/src/core/features/h5p/assets/styles/h5p-admin.css @@ -0,0 +1,358 @@ +/* Administration interface styling */ + +.h5p-content { + border: 1px solid #DDD; + border-radius: 3px; + padding: 10px; +} + +.h5p-admin-table, +.h5p-admin-table > tbody { + border: none; + width: 100%; +} + +.h5p-admin-table tr:nth-child(odd), +.h5p-data-view tr:nth-child(odd) { + background-color: #F9F9F9; +} +.h5p-admin-table tbody tr:hover { + background-color: #EEE; +} +.h5p-admin-table.empty { + padding: 1em; + background-color: #EEE; + font-size: 1.2em; + font-weight: bold; +} + +.h5p-admin-table.libraries th:last-child, +.h5p-admin-table.libraries td:last-child { + text-align: right; +} + +.h5p-admin-buttons-wrapper { + white-space: nowrap; +} + +.h5p-admin-table.libraries button { + font-size: 2em; + cursor: pointer; + border: 1px solid #AAA; + border-radius: .2em; + background-color: #e0e0e0; + text-shadow: 0 0 0.5em #fff; + padding: 0; + line-height: 1em; + width: 1.125em; + height: 1.05em; + text-indent: -0.125em; + margin: 0.125em 0.125em 0 0.125em; +} +.h5p-admin-upgrade-library:before { + font-family: 'H5P'; + content: "\e888"; +} +.h5p-admin-view-library:before { + font-family: 'H5P'; + content: "\e889"; +} +.h5p-admin-delete-library:before { + font-family: 'H5P'; + content: "\e890"; +} + +.h5p-admin-table.libraries button:hover { + background-color: #d0d0d0; +} +.h5p-admin-table.libraries button:disabled:hover { + background-color: #e0e0e0; + cursor: default; +} + +.h5p-admin-upgrade-library { + color: #339900; +} +.h5p-admin-view-library { + color: #0066cc; +} +.h5p-admin-delete-library { + color: #990000; +} +.h5p-admin-delete-library:disabled, +.h5p-admin-upgrade-library:disabled { + cursor: default; + color: #c0c0c0; +} + +.h5p-library-info { + padding: 1em 1em; + margin: 1em 0; + + width: 350px; + + border: 1px solid #DDD; + border-radius: 3px; +} + +/* Labeled field (label + value) */ +.h5p-labeled-field { + border-bottom: 1px solid #ccc; +} +.h5p-labeled-field:last-child { + border-bottom: none; +} + +.h5p-labeled-field .h5p-label { + display: inline-block; + min-width: 150px; + font-size: 1.2em; + font-weight: bold; + padding: 0.2em; +} + +.h5p-labeled-field .h5p-value { + display: inline-block; + padding: 0.2em; +} + +/* Search element */ +.h5p-content-search { + display: inline-block; + position: relative; + + width: 100%; + padding: 5px 0; + margin-top: 10px; + + border: 1px solid #CCC; + border-radius: 3px; + box-shadow: 2px 2px 5px #888888; +} +.h5p-content-search:before { + font-family: 'H5P'; + vertical-align: bottom; + content: "\e88a"; + font-size: 2em; + line-height: 1.25em; +} +.h5p-content-search input { + font-size: 120%; + line-height: 120%; +} +.h5p-admin-search-results { + margin-left: 10px; + color: #888; +} + +.h5p-admin-pager-size-selector { + position: absolute; + right: 10px; + top: .75em; + display: inline-block; +} +.h5p-admin-pager-size-selector > span { + padding: 5px; + margin-left: 10px; + cursor: pointer; + border: 1px solid #CCC; + border-radius: 3px; +} +.h5p-admin-pager-size-selector > span.selected { + background-color: #edf5fa; +} +.h5p-admin-pager-size-selector > span:hover { + background-color: #555; + color: #FFF; +} + +/* Generic "javascript"-action button */ +button.h5p-admin { + border: 1px solid #AAA; + border-radius: 5px; + padding: 3px 10px; + background-color: #EEE; + cursor: pointer; + display: inline-block; + text-align: center; + color: #222; +} +button.h5p-admin:hover { + background-color: #555; + color: #FFF; +} +button.h5p-admin.disabled, +button.h5p-admin.disabled:hover { + cursor: auto; + color: #CCC; + background-color: #FFF; +} + +/* Pager element */ +.h5p-content-pager { + display: inline-block; + border: 1px solid #CCC; + border-radius: 3px; + box-shadow: 2px 2px 5px #888888; + width: 100%; + text-align: center; + padding: 3px 0; +} +.h5p-content-pager > button { + min-width: 80px; + font-size: 130%; + line-height: 130%; + border: none; + background: none; + font-family: 'H5P'; + font-size: 1.4em; +} +.h5p-content-pager > button:focus { + outline: 0; +} +.h5p-content-pager > button:last-child { + margin-left: 10px; +} +.h5p-content-pager > .pager-info { + cursor: pointer; + padding: 5px; + border-radius: 3px; +} +.h5p-content-pager > .pager-info:hover { + background-color: #555; + color: #FFF; +} +.h5p-content-pager > .pager-info, +.h5p-content-pager > .h5p-pager-goto { + margin: 0 10px; + line-height: 130%; + display: inline-block; +} + +.h5p-admin-header { + margin-top: 1.5em; +} +#h5p-library-upload-form.h5p-admin-upload-libraries-form, +#h5p-content-type-cache-update-form.h5p-admin-upload-libraries-form { + position: relative; + margin: 0; + +} +.h5p-admin-upload-libraries-form .form-submit { + position: absolute; + top: 0; + right: 0; +} +.h5p-spinner { + padding: 0 0.5em; + font-size: 1.5em; + font-weight: bold; +} +#h5p-admin-container .h5p-admin-center { + text-align: center; +} +.h5p-pagination { + text-align: center; +} +.h5p-pagination > span, .h5p-pagination > input { + margin: 0 1em; +} +.h5p-data-view input[type="text"] { + margin-bottom: 0.5em; + margin-right: 0.5em; + float: left; +} +.h5p-data-view input[type="text"]::-ms-clear { + display: none; +} + +.h5p-data-view .h5p-others-contents-toggler-wrapper { + float: right; + line-height: 2; + margin-right: 0.5em; +} + +.h5p-data-view .h5p-others-contents-toggler-label { + font-size: 14px; +} + +.h5p-data-view .h5p-others-contents-toggler { + margin-right: 0.5em; +} + +.h5p-data-view th[role="button"] { + cursor: pointer; +} +.h5p-data-view th[role="button"].h5p-sort:after, +.h5p-data-view th[role="button"]:hover:after, +.h5p-data-view th[role="button"].h5p-sort.h5p-reverse:hover:after { + content: "\25BE"; + position: relative; + left: 0.5em; + top: -1px; +} +.h5p-data-view th[role="button"].h5p-sort.h5p-reverse:after, +.h5p-data-view th[role="button"].h5p-sort:hover:after { + content: "\25B4"; + top: -2px; +} +.h5p-data-view th[role="button"]:hover:after, +.h5p-data-view th[role="button"].h5p-sort.h5p-reverse:hover:after, +.h5p-data-view th[role="button"].h5p-sort:hover:after { + color: #999; +} +.h5p-data-view .h5p-facet { + cursor: pointer; + color: #0073aa; + outline: none; +} +.h5p-data-view .h5p-facet:hover, +.h5p-data-view .h5p-facet:active { + color: #00a0d2; +} +.h5p-data-view .h5p-facet:focus { + color: #124964; + box-shadow: 0 0 0 1px #5b9dd9,0 0 2px 1px rgba(30,140,190,.8); +} +.h5p-data-view .h5p-facet-wrapper { + line-height: 23px; +} +.h5p-data-view .h5p-facet-tag { + margin: 2px 0 0 0.5em; + font-size: 12px; + background: #e8e8e8; + border: 1px solid #cbcbcc; + border-radius: 5px; + color: #5d5d5d; + padding: 0 24px 0 10px; + display: inline-block; + position: relative; +} +.h5p-data-view .h5p-facet-tag > span { + position: absolute; + right: 0; + top: auto; + bottom: auto; + font-size: 18px; + color: #a2a2a2; + outline: none; + width: 21px; + text-indent: 4px; + letter-spacing: 10px; + overflow: hidden; + cursor: pointer; +} +.h5p-data-view .h5p-facet-tag > span:before { + content: "×"; + font-weight: bold; +} +.h5p-data-view .h5p-facet-tag > span:hover, +.h5p-data-view .h5p-facet-tag > span:focus { + color: #a20000; +} +.h5p-data-view .h5p-facet-tag > span:active { + color: #d20000; +} +.content-upgrade-log { + color: red; +} diff --git a/src/core/features/h5p/assets/styles/h5p-confirmation-dialog.css b/src/core/features/h5p/assets/styles/h5p-confirmation-dialog.css new file mode 100644 index 000000000..e849c23e6 --- /dev/null +++ b/src/core/features/h5p/assets/styles/h5p-confirmation-dialog.css @@ -0,0 +1,183 @@ +.h5p-confirmation-dialog-background { + position: fixed; + height: 100%; + width: 100%; + left: 0; + top: 0; + + background: rgba(44, 44, 44, 0.9); + opacity: 1; + visibility: visible; + -webkit-transition: opacity 0.1s, linear 0s, visibility 0s linear 0s; + transition: opacity 0.1s linear 0s, visibility 0s linear 0s; + + z-index: 201; +} + +.h5p-confirmation-dialog-background.hidden { + display: none; +} + +.h5p-confirmation-dialog-background.hiding { + opacity: 0; + visibility: hidden; + -webkit-transition: opacity 0.1s, linear 0s, visibility 0s linear 0.1s; + transition: opacity 0.1s linear 0s, visibility 0s linear 0.1s; +} + +.h5p-confirmation-dialog-popup:focus { + outline: none; +} + +.h5p-confirmation-dialog-popup { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + + box-sizing: border-box; + max-width: 35em; + min-width: 25em; + + top: 2em; + left: 50%; + -webkit-transform: translate(-50%, 0%); + -ms-transform: translate(-50%, 0%); + transform: translate(-50%, 0%); + + color: #555; + box-shadow: 0 0 6px 6px rgba(10,10,10,0.3); + + -webkit-transition: transform 0.1s ease-in; + transition: transform 0.1s ease-in; +} + +.h5p-confirmation-dialog-popup.hidden { + -webkit-transform: translate(-50%, 50%); + -ms-transform: translate(-50%, 50%); + transform: translate(-50%, 50%); +} + +.h5p-confirmation-dialog-header { + padding: 1.5em; + background: #fff; + color: #356593; +} + +.h5p-confirmation-dialog-header-text { + font-size: 1.25em; +} + +.h5p-confirmation-dialog-body { + background: #fafbfc; + border-top: solid 1px #dde0e9; + padding: 1.25em 1.5em; +} + +.h5p-confirmation-dialog-text { + margin-bottom: 1.5em; +} + +.h5p-confirmation-dialog-buttons { + float: right; +} + +button.h5p-confirmation-dialog-exit:visited, +button.h5p-confirmation-dialog-exit:link, +button.h5p-confirmation-dialog-exit { + position: absolute; + background: none; + border: none; + font-size: 2.5em; + top: -0.9em; + right: -1.15em; + color: #fff; + cursor: pointer; + text-decoration: none; +} + +button.h5p-confirmation-dialog-exit:focus, +button.h5p-confirmation-dialog-exit:hover { + color: #E4ECF5; +} + +.h5p-confirmation-dialog-exit:before { + font-family: "H5P"; + content: "\e890"; +} + +.h5p-core-button.h5p-confirmation-dialog-confirm-button { + padding-left: 0.75em; + margin-bottom: 0; +} + +.h5p-core-button.h5p-confirmation-dialog-confirm-button:before { + content: "\e601"; + margin-top: -6px; + display: inline-block; +} + +.h5p-confirmation-dialog-popup.offline .h5p-confirmation-dialog-buttons { + float: none; + text-align: center; +} + +.h5p-confirmation-dialog-popup.offline .count-down { + font-family: Arial; + margin-top: 0.15em; + color: #000; +} + +.h5p-confirmation-dialog-popup.offline .h5p-confirmation-dialog-confirm-button:before { + content: "\e90b"; + font-weight: normal; + vertical-align: text-bottom; +} + +.throbber-wrapper { + display: none; + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 1; + background: rgba(44, 44, 44, 0.9); +} + +.throbber-wrapper.show { + display: block; +} + +.throbber-wrapper .throbber-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.throbber-wrapper .sending-requests-throbber{ + position: absolute; + top: 7em; + left: 50%; + transform: translateX(-50%); +} + +.throbber-wrapper .sending-requests-throbber:before { + display: block; + font-family: 'H5P'; + content: "\e90b"; + color: white; + font-size: 10em; + animation: request-throbber 1.5s infinite linear; +} + +@keyframes request-throbber { + from { + transform: rotate(0); + } + + to { + transform: rotate(359deg); + } +} diff --git a/src/core/features/h5p/assets/styles/h5p-core-button.css b/src/core/features/h5p/assets/styles/h5p-core-button.css new file mode 100644 index 000000000..e6511dc38 --- /dev/null +++ b/src/core/features/h5p/assets/styles/h5p-core-button.css @@ -0,0 +1,60 @@ +button.h5p-core-button:visited, +button.h5p-core-button:link, +button.h5p-core-button { + font-family: "Open Sans", sans-serif; + font-weight: 600; + font-size: 1em; + line-height: 1.2; + padding: 0.5em 1.25em; + border-radius: 2em; + + background: #2579c6; + color: #fff; + + cursor: pointer; + border: none; + box-shadow: none; + outline: none; + + display: inline-block; + text-align: center; + text-shadow: none; + vertical-align: baseline; + text-decoration: none; + + -webkit-transition: initial; + transition: initial; +} +button.h5p-core-button:focus { + background: #1f67a8; +} +button.h5p-core-button:hover { + background: rgba(31, 103, 168, 0.83); +} +button.h5p-core-button:active { + background: #104888; +} +button.h5p-core-button:before { + font-family: 'H5P'; + padding-right: 0.15em; + font-size: 1.5em; + vertical-align: middle; + line-height: 0.7; +} +button.h5p-core-cancel-button:visited, +button.h5p-core-cancel-button:link, +button.h5p-core-cancel-button { + border: none; + background: none; + color: #a00; + margin-right: 1em; + font-size: 1em; + text-decoration: none; + cursor: pointer; +} +button.h5p-core-cancel-button:hover, +button.h5p-core-cancel-button:focus { + background: none; + border: none; + color: #e40000; +} diff --git a/src/core/features/h5p/assets/styles/h5p.css b/src/core/features/h5p/assets/styles/h5p.css new file mode 100644 index 000000000..1f89e4ead --- /dev/null +++ b/src/core/features/h5p/assets/styles/h5p.css @@ -0,0 +1,566 @@ +/* General CSS for H5P. Licensed under the MIT License.*/ + +/* Custom H5P font to use for icons. */ +@font-face { + font-family: 'h5p'; + src: url('../fonts/h5p-core-23.eot?mz1lkp'); + src: url('../fonts/h5p-core-23.eot?mz1lkp#iefix') format('embedded-opentype'), + url('../fonts/h5p-core-23.ttf?mz1lkp') format('truetype'), + url('../fonts/h5p-core-23.woff?mz1lkp') format('woff'), + url('../fonts/h5p-core-23.svg?mz1lkp#h5p') format('svg'); + font-weight: normal; + font-style: normal; +} + +html.h5p-iframe, html.h5p-iframe > body { + font-family: Sans-Serif; /* Use the browser's default sans-serif font. (Since Heletica doesn't look nice on Windows, and Arial on OS X.) */ + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} +.h5p-semi-fullscreen, .h5p-fullscreen, html.h5p-iframe .h5p-container { + overflow: hidden; +} +.h5p-content { + position: relative; + background: #fefefe; + border: 1px solid #EEE; + border-bottom: none; + box-sizing: border-box; + -moz-box-sizing: border-box; +} +.h5p-noselect +{ + -khtml-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; +} +html.h5p-iframe .h5p-content { + font-size: 16px; + line-height: 1.5em; + width: 100%; + height: auto; +} +html.h5p-iframe .h5p-fullscreen .h5p-content, +html.h5p-iframe .h5p-semi-fullscreen .h5p-content { + height: 100%; +} +.h5p-content.h5p-no-frame, +.h5p-fullscreen .h5p-content, +.h5p-semi-fullscreen .h5p-content { + border: 0; +} +.h5p-container { + position: relative; + z-index: 1; +} +.h5p-iframe-wrapper.h5p-fullscreen { + background-color: #000; +} +body.h5p-semi-fullscreen { + position: fixed; + width: 100%; + height: 100%; +} +.h5p-container.h5p-semi-fullscreen { + position: fixed; + top: 0; + left: 0; + z-index: 101; + width: 100%; + height: 100%; + background-color: #FFF; +} + +.h5p-content-controls { + margin: 0; + position: absolute; + right: 0; + top: 0; + z-index: 3; +} +.h5p-fullscreen .h5p-content-controls { + display: none; +} + +.h5p-content-controls > a:link, .h5p-content-controls > a:visited, a.h5p-disable-fullscreen:link, a.h5p-disable-fullscreen:visited { + color: #e5eef6; +} + +.h5p-enable-fullscreen:before { + font-family: 'H5P'; + content: "\e88c"; +} +.h5p-disable-fullscreen:before { + font-family: 'H5P'; + content: "\e891"; +} +.h5p-enable-fullscreen, .h5p-disable-fullscreen { + cursor: pointer; + color: #EEE; + background: rgb(0,0,0); + background: rgba(0,0,0,0.3); + line-height: 0.975em; + font-size: 2em; + width: 1.125em; + height: 1em; + text-indent: 0.04em; +} +.h5p-disable-fullscreen { + line-height: 0.925em; + width: 1.1em; + height: 0.9em; +} + +.h5p-enable-fullscreen:focus, +.h5p-disable-fullscreen:focus { + outline-style: solid; + outline-width: 1px; + outline-offset: 0.25em; +} + +.h5p-enable-fullscreen:hover, .h5p-disable-fullscreen:hover { + background: rgba(0,0,0,0.5); +} +.h5p-semi-fullscreen .h5p-enable-fullscreen { + display: none; +} + +div.h5p-fullscreen { + width: 100%; + height: 100%; +} +.h5p-iframe-wrapper { + width: auto; + height: auto; +} + +.h5p-fullscreen .h5p-iframe-wrapper, +.h5p-semi-fullscreen .h5p-iframe-wrapper { + width: 100%; + height: 100%; +} + +.h5p-iframe-wrapper.h5p-semi-fullscreen { + width: auto; + height: auto; + background: black; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100001; +} +.h5p-iframe-wrapper.h5p-semi-fullscreen .buttons { + position: absolute; + top: 0; + right: 0; + z-index: 20; +} +.h5p-iframe-wrapper iframe.h5p-iframe { + /* Hack for IOS landscape / portrait */ + width: 10px; + min-width: 100%; + *width: 100%; + /* End of hack */ + height: 100%; + z-index: 10; + overflow: hidden; + border: 0; + display: block; +} + +.h5p-content ul.h5p-actions { + box-sizing: border-box; + -moz-box-sizing: border-box; + overflow: hidden; + list-style: none; + padding: 0px 10px; + margin: 0; + height: 25px; + font-size: 12px; + background: #FAFAFA; + border-top: 1px solid #EEE; + border-bottom: 1px solid #EEE; + clear: both; + font-family: Sans-Serif; +} +.h5p-fullscreen .h5p-actions, .h5p-semi-fullscreen .h5p-actions { + display: none; +} +.h5p-actions > .h5p-button { + float: left; + cursor: pointer; + margin: 0 0.5em 0 0; + background: none; + padding: 0 0.75em 0 0.25em; + vertical-align: top; + color: #999; + text-decoration: none; + outline: none; + line-height: 23px; +} +.h5p-actions > .h5p-button:hover { + color: #666; +} +.h5p-actions > .h5p-button:active, +.h5p-actions > .h5p-button:focus, +.h5p-actions .h5p-link:active, +.h5p-actions .h5p-link:focus { + color: #666; +} +.h5p-actions > .h5p-button:focus, +.h5p-actions .h5p-link:focus { + outline-style: solid; + outline-width: thin; + outline-offset: -2px; + outline-color: #9ecaed; +} +.h5p-actions > .h5p-button:before { + font-family: 'H5P'; + font-size: 20px; + line-height: 20px; + vertical-align: top; + padding-right: 0; +} +.h5p-actions > .h5p-button.h5p-export:before { + content: "\e90b"; +} +.h5p-actions > .h5p-button.h5p-copyrights:before { + content: "\e88f"; +} +.h5p-actions > .h5p-button.h5p-embed:before { + content: "\e892"; +} +.h5p-actions .h5p-link { + float: right; + margin-right: 0; + font-size: 2.0em; + line-height: 23px; + overflow: hidden; + color: #999; + text-decoration: none; + outline: none; +} +.h5p-actions .h5p-link:before { + font-family: 'H5P'; + content: "\e88e"; + vertical-align: bottom; +} +.h5p-actions > li { + margin: 0; + list-style: none; +} +.h5p-popup-dialog { + position: absolute; + top: 0; + left: 0; + width: 100%; + min-height: 100%; + z-index: 100; + padding: 2em; + box-sizing: border-box; + -moz-box-sizing: border-box; + opacity: 0; + -webkit-transition: opacity 0.2s; + -moz-transition: opacity 0.2s; + -o-transition: opacity 0.2s; + transition: opacity 0.2s; + background:#000; + background:rgba(0,0,0,0.75); +} +.h5p-popup-dialog.h5p-open { + opacity: 1; +} +.h5p-popup-dialog .h5p-inner { + box-sizing: border-box; + -moz-box-sizing: border-box; + background: #fff; + height: 100%; + max-height: 100%; + position: relative; +} +.h5p-popup-dialog .h5p-inner > h2 { + position: absolute; + box-sizing: border-box; + -moz-box-sizing: border-box; + width: 100%; + margin: 0; + background: #eee; + display: block; + color: #656565; + font-size: 1.25em; + padding: 0.325em 0.5em 0.25em; + line-height: 1.25em; + border-bottom: 1px solid #ccc; + z-index: 2; +} +.h5p-popup-dialog .h5p-inner > h2 > a { + font-size: 12px; + margin-left: 1em; +} +.h5p-embed-dialog .h5p-inner, +.h5p-reuse-dialog .h5p-inner, +.h5p-content-user-data-reset-dialog .h5p-inner { + min-width: 316px; + max-width: 400px; + left: 50%; + top: 50%; + transform: translateX(-50%); +} +.h5p-embed-dialog .h5p-embed-code-container, +.h5p-embed-size { + resize: none; + outline: none; + width: 100%; + padding: 0.375em 0.5em 0.25em; + margin: 0; + overflow: hidden; + border: 1px solid #ccc; + box-shadow: 0 1px 2px 0 #d0d0d0 inset; + font-size: 0.875em; + letter-spacing: 0.065em; + font-family: sans-serif; + white-space: pre; + line-height: 1.5em; + height: 2.0714em; + background: #f5f5f5; + box-sizing: border-box; + -moz-box-sizing: border-box; +} +.h5p-embed-dialog .h5p-embed-code-container:focus { + height: 5em; +} +.h5p-embed-size { + width: 3.5em; + text-align: right; + margin: 0.5em 0; + line-height: 2em; +} +.h5p-popup-dialog .h5p-scroll-content { + border-top: 2.25em solid transparent; + padding: 1em; + box-sizing: border-box; + -moz-box-sizing: border-box; + color: #555555; + z-index: 1; +} +.h5p-popup-dialog.h5p-open .h5p-scroll-content { + overflow: auto; + overflow-x: hidden; + overflow-y: auto; + height: 100%; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar { + width: 8px; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar-track { + background: #e0e0e0; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar-thumb { + box-shadow: 0 0 10px #000 inset; + border-radius: 4px; +} +.h5p-popup-dialog .h5p-close { + cursor: pointer; +} +.h5p-popup-dialog .h5p-close:after { + font-family: 'H5P'; + content: "\e894"; + font-size: 2em; + position: absolute; + right: 0; + top: 0; + width: 1.125em; + height: 1.125em; + line-height: 1.125em; + color: #656565; + cursor: pointer; + text-indent: -0.065em; + z-index: 3 +} +.h5p-popup-dialog .h5p-close:hover:after, +.h5p-popup-dialog .h5p-close:focus:after { + color: #454545; +} +.h5p-popup-dialog .h5p-close:active:after { + color: #252525; +} +.h5p-poopup-dialog h2 { + margin: 0.25em 0 0.5em; +} +.h5p-popup-dialog h3 { + margin: 0.75em 0 0.25em; +} +.h5p-popup-dialog dl { + margin: 0.25em 0 0.75em; +} +.h5p-popup-dialog dt { + float: left; + margin: 0 0.75em 0 0; +} +.h5p-popup-dialog dt:after { + content: ':'; +} +.h5p-popup-dialog dd { + margin: 0; +} +.h5p-expander { + cursor: pointer; + font-size: 1.125em; + outline: none; + margin: 0.5em 0 0; + display: inline-block; +} +.h5p-expander:before { + content: "+"; + width: 1em; + display: inline-block; + font-weight: bold; +} +.h5p-expander.h5p-open:before { + content: "-"; + text-indent: 0.125em; +} +.h5p-expander:hover, +.h5p-expander:focus { + color: #303030; +} +.h5p-expander:active { + color: #202020; +} +.h5p-expander-content { + display: none; +} +.h5p-expander-content p { + margin: 0.5em 0; +} +.h5p-content-copyrights { + border-left: 0.25em solid #d0d0d0; + margin-left: 0.25em; + padding-left: 0.25em; +} +.h5p-throbber { + background: url('../images/throbber.gif?ver=1.2.1') 10px center no-repeat; + padding-left: 38px; + min-height: 30px; + line-height: 30px; +} +.h5p-dialog-ok-button { + cursor: default; + float: right; + outline: none; + border: 2px solid #ccc; + padding: 0.25em 0.75em 0.125em; + background: #eee; +} +.h5p-dialog-ok-button:hover, +.h5p-dialog-ok-button:focus { + background: #fafafa; +} +.h5p-dialog-ok-button:active { + background: #eeffee; +} +.h5p-big-button { + line-height: 1.25; + display: block; + position: relative; + cursor: pointer; + width: 100%; + padding: 1em 1em 1em 3.75em; + text-align: left; + border: 1px solid #dedede; + background: linear-gradient(#ffffff, #f1f1f2); + border-radius: 0.25em; +} +.h5p-big-button:before { + font-family: 'h5p'; + content: "\e893"; + line-height: 1; + font-size: 3em; + color: #2747f7; + position: absolute; + left: 0.125em; + top: 0.125em; +} +.h5p-copy-button:before { + content: "\e905"; +} +.h5p-big-button:hover { + border: 1px solid #2747f7; + background: #eff1fe; +} +.h5p-big-button:active { + border: 1px solid #dedede; + background: #dfe4fe; +} +.h5p-button-title { + color: #2747f7; + font-size: 15px; + font-weight: bold; + margin-bottom: 0.5em; +} +.h5p-button-description { + color: #757575; +} +.h5p-horizontal-line-text { + border-top: 1px solid #dadada; + line-height: 1; + color: #474747; + text-align: center; + position: relative; + margin: 1.25em 0; +} +.h5p-horizontal-line-text > span { + background: white; + padding: 0.5em; + position: absolute; + top: -1em; + left: 50%; + transform: translateX(-50%); +} +.h5p-toast { + font-size: 0.75em; + background-color: rgba(0, 0, 0, 0.9); + color: #fff; + z-index: 110; + position: absolute; + padding: 0 0.5em; + line-height: 2; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + top: 0; + opacity: 1; + visibility: visible; + transition: opacity 1s; +} +.h5p-toast-disabled { + opacity: 0; + visibility: hidden; +} + + +/* This is loaded as part of Core and not Editor since this needs to be outside the editor iframe */ +.h5peditor-semi-fullscreen { + width: 100%; + height: 100%; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 101; +} +iframe.h5peditor-semi-fullscreen { + background: #fff; + z-index: 100001; +} + +.h5p-content.using-mouse *:not(textarea):focus { + outline: none !important; +} diff --git a/src/core/features/h5p/lang.json b/src/core/features/h5p/lang.json new file mode 100644 index 000000000..19018057f --- /dev/null +++ b/src/core/features/h5p/lang.json @@ -0,0 +1,93 @@ +{ + "additionallicenseinfo": "Any additional information about the licence", + "author": "Author", + "authorcomments": "Author comments", + "authorcommentsdescription": "Comments for the editor of the content. (This text will not be published as a part of the copyright info.)", + "authorname": "Author's name", + "authorrole": "Author's role", + "by": "by", + "cancellabel": "Cancel", + "ccattribution": "Attribution (CC BY)", + "ccattributionnc": "Attribution-NonCommercial (CC BY-NC)", + "ccattributionncnd": "Attribution-NonCommercial-NoDerivs (CC BY-NC-ND)", + "ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", + "ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", + "ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "ccpdd": "Public Domain Dedication (CC0)", + "changedby": "Changed by", + "changedescription": "Description of change", + "changelog": "Changelog", + "changeplaceholder": "Photo cropped, text changed, etc.", + "close": "Close", + "confirmdialogbody": "Please confirm that you wish to proceed. This action cannot be undone.", + "confirmdialogheader": "Confirm action", + "confirmlabel": "Confirm", + "connectionLost": "Connection lost. Results will be stored and sent when the connection is reestablished.", + "connectionReestablished": "Connection reestablished.", + "contentCopied": "Content is copied to the clipboard", + "contentchanged": "This content has changed since you last used it.", + "contenttype": "Content type", + "copyright": "Rights of use", + "copyrightinfo": "Copyright information", + "copyrightstring": "Copyright", + "copyrighttitle": "View copyright information for this content.", + "creativecommons": "Creative Commons", + "date": "Date", + "disablefullscreen": "Disable fullscreen", + "download": "Download", + "downloadtitle": "Download this content as a H5P file.", + "editor": "Editor", + "embed": "Embed", + "embedtitle": "View the embed code for this content.", + "errorgetemail": "Error obtaining the user email. Please check your connection and try again.", + "fullscreen": "Fullscreen", + "gpl": "General Public License v3", + "h5ptitle": "Visit h5p.org to check out more content.", + "hideadvanced": "Hide advanced", + "license": "Licence", + "licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "licenseCC010U": "CC0 1.0 Universal", + "licenseCC10": "1.0 Generic", + "licenseCC20": "2.0 Generic", + "licenseCC25": "2.5 Generic", + "licenseCC30": "3.0 Unported", + "licenseCC40": "4.0 International", + "licenseGPL": "General Public License", + "licenseV1": "Version 1", + "licenseV2": "Version 2", + "licenseV3": "Version 3", + "licensee": "Licensee", + "licenseextras": "Licence extras", + "licenseversion": "Licence version", + "nocopyright": "No copyright information available for this content.", + "offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", + "offlineDialogHeader": "Your connection to the server was lost", + "offlineDialogRetryButtonLabel": "Retry now", + "offlineDialogRetryMessage": "Retrying in :num....", + "offlineSuccessfulSubmit": "Successfully submitted results.", + "offlinedisabled": "The site doesn't allow downloading H5P packages.", + "originator": "Originator", + "pd": "Public Domain", + "pddl": "Public Domain Dedication and Licence", + "pdm": "Public Domain Mark (PDM)", + "play": "Play H5P", + "resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", + "resubmitScores": "Attempting to submit stored results.", + "reuse": "Reuse", + "reuseContent": "Reuse content", + "reuseDescription": "Reuse this content.", + "showadvanced": "Show advanced", + "showless": "Show less", + "showmore": "Show more", + "size": "Size", + "source": "Source", + "startingover": "You'll be starting over.", + "sublevel": "Sublevel", + "thumbnail": "Thumbnail", + "title": "Title", + "undisclosed": "Undisclosed", + "year": "Year", + "years": "Year(s)", + "yearsfrom": "Years (from)", + "yearsto": "Years (to)" +} \ No newline at end of file From c83ff34ae0a3b31796c499e794982f2ee621e1df Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 16 Dec 2020 13:08:20 +0100 Subject: [PATCH 2/7] MOBILE-3666 xapi: Implement XAPI services --- src/core/features/features.module.ts | 2 + .../features/xapi/services/database/xapi.ts | 74 +++++++++ src/core/features/xapi/services/offline.ts | 144 +++++++++++++++++ src/core/features/xapi/services/xapi.ts | 146 ++++++++++++++++++ src/core/features/xapi/xapi.module.ts | 32 ++++ 5 files changed, 398 insertions(+) create mode 100644 src/core/features/xapi/services/database/xapi.ts create mode 100644 src/core/features/xapi/services/offline.ts create mode 100644 src/core/features/xapi/services/xapi.ts create mode 100644 src/core/features/xapi/xapi.module.ts diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index ff237f5d9..b3d5c06e5 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -25,6 +25,7 @@ import { CoreSiteHomeModule } from './sitehome/sitehome.module'; import { CoreTagModule } from './tag/tag.module'; import { CoreUserModule } from './user/user.module'; import { CorePushNotificationsModule } from './pushnotifications/pushnotifications.module'; +import { CoreXAPIModule } from './xapi/xapi.module'; @NgModule({ imports: [ @@ -39,6 +40,7 @@ import { CorePushNotificationsModule } from './pushnotifications/pushnotificatio CoreTagModule, CoreUserModule, CorePushNotificationsModule, + CoreXAPIModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/xapi/services/database/xapi.ts b/src/core/features/xapi/services/database/xapi.ts new file mode 100644 index 000000000..6164804fd --- /dev/null +++ b/src/core/features/xapi/services/database/xapi.ts @@ -0,0 +1,74 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreXAPIOfflineProvider service. + */ +export const STATEMENTS_TABLE_NAME = 'core_xapi_statements'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreXAPIOfflineProvider', + version: 1, + tables: [ + { + name: STATEMENTS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'contextid', + type: 'INTEGER', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'statements', + type: 'TEXT', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'extra', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * Structure of statement data stored in DB. + */ +export type CoreXAPIStatementDBRecord = { + id: number; // ID. + contextid: number; // Context ID of the statements. + component: string; // Component to send the statements to. + statements: string; // Statements (JSON-encoded). + timecreated: number; // When were the statements created. + courseid?: number; // Course ID if the context is inside a course. + extra?: string; // Extra data. +}; diff --git a/src/core/features/xapi/services/offline.ts b/src/core/features/xapi/services/offline.ts new file mode 100644 index 000000000..32563902c --- /dev/null +++ b/src/core/features/xapi/services/offline.ts @@ -0,0 +1,144 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME } from './database/xapi'; + +/** + * Service to handle offline xAPI. + */ +@Injectable({ providedIn: 'root' }) +export class CoreXAPIOfflineProvider { + + /** + * Check if there are offline statements to send for a context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline statements, false otherwise. + */ + async contextHasStatements(contextId: number, siteId?: string): Promise { + const statementsList = await this.getContextStatements(contextId, siteId); + + return statementsList && statementsList.length > 0; + } + + /** + * Delete certain statements. + * + * @param id ID of the statements. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteStatements(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(STATEMENTS_TABLE_NAME, { id }); + } + + /** + * Delete all statements of a certain context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteStatementsForContext(contextId: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(STATEMENTS_TABLE_NAME, { contextid: contextId }); + } + + /** + * Get all offline statements. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with all the data. + */ + async getAllStatements(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecords(STATEMENTS_TABLE_NAME, undefined, 'timecreated ASC'); + } + + /** + * Get statements for a context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getContextStatements(contextId: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecords(STATEMENTS_TABLE_NAME, { contextid: contextId }, 'timecreated ASC'); + } + + /** + * Get certain statements. + * + * @param id ID of the statements. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getStatements(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecord(STATEMENTS_TABLE_NAME, { id }); + } + + /** + * Save statements. + * + * @param contextId Context ID. + * @param component Component to send the statements to. + * @param statements Statements (JSON-encoded). + * @param options Options. + * @return Promise resolved when statements are successfully saved. + */ + async saveStatements( + contextId: number, + component: string, + statements: string, + options?: CoreXAPIOfflineSaveStatementsOptions, + ): Promise { + const db = await CoreSites.instance.getSiteDb(options?.siteId); + + const entry: Omit = { + contextid: contextId, + component: component, + statements: statements, + timecreated: Date.now(), + courseid: options?.courseId, + extra: options?.extra, + }; + + await db.insertRecord(STATEMENTS_TABLE_NAME, entry); + } + +} + +export class CoreXAPIOffline extends makeSingleton(CoreXAPIOfflineProvider) {} + +/** + * Options to pass to saveStatements function. + */ +export type CoreXAPIOfflineSaveStatementsOptions = { + courseId?: number; // Course ID if the context is inside a course. + extra?: string; // Extra data to store. + siteId?: string; // Site ID. If not defined, current site. +}; diff --git a/src/core/features/xapi/services/xapi.ts b/src/core/features/xapi/services/xapi.ts new file mode 100644 index 000000000..7ea06df17 --- /dev/null +++ b/src/core/features/xapi/services/xapi.ts @@ -0,0 +1,146 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSite } from '@classes/site'; +import { CoreXAPIOffline, CoreXAPIOfflineSaveStatementsOptions } from './offline'; +import { makeSingleton } from '@singletons'; + +/** + * Service to provide XAPI functionalities. + */ +@Injectable({ providedIn: 'root' }) +export class CoreXAPIProvider { + + /** + * Returns whether or not WS to post XAPI statement is available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.9 + */ + async canPostStatements(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canPostStatementsInSite(site); + } + + /** + * Returns whether or not WS to post XAPI statement is available in a certain site. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.9 + */ + canPostStatementsInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site && site.wsAvailable('core_xapi_statement_post')); + } + + /** + * Get URL for XAPI events. + * + * @param contextId Context ID. + * @param type Type (e.g. 'activity'). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async getUrl(contextId: number, type: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return CoreTextUtils.instance.concatenatePaths(site.getURL(), `xapi/${type}/${contextId}`); + } + + /** + * Post statements. + * + * @param contextId Context ID. + * @param component Component. + * @param json JSON string to send. + * @param options Options. + * @return Promise resolved with boolean: true if response was sent to server, false if stored in device. + */ + async postStatements( + contextId: number, + component: string, + json: string, + options?: CoreXAPIPostStatementsOptions, + ): Promise { + + options = options || {}; + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await CoreXAPIOffline.instance.saveStatements(contextId, component, json, options); + + return false; + }; + + if (!CoreApp.instance.isOnline() || options.offline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.postStatementsOnline(component, json, options.siteId); + + return true; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } else { + // Couldn't connect to server, store it offline. + return storeOffline(); + } + } + } + + /** + * Post statements. It will fail if offline or cannot connect. + * + * @param component Component. + * @param json JSON string to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async postStatementsOnline(component: string, json: string, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const data = { + component: component, + requestjson: json, + }; + + return site.write('core_xapi_statement_post', data); + } + +} + +export class CoreXAPI extends makeSingleton(CoreXAPIProvider) {} + +/** + * Options to pass to postStatements function. + */ +export type CoreXAPIPostStatementsOptions = CoreXAPIOfflineSaveStatementsOptions & { + offline?: boolean; // Whether to force storing it in offline. +}; diff --git a/src/core/features/xapi/xapi.module.ts b/src/core/features/xapi/xapi.module.ts new file mode 100644 index 000000000..8671585e0 --- /dev/null +++ b/src/core/features/xapi/xapi.module.ts @@ -0,0 +1,32 @@ +// (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 { CORE_SITE_SCHEMAS } from '@services/sites'; +import { STATEMENTS_TABLE_NAME } from './services/database/xapi'; + +@NgModule({ + imports: [], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + STATEMENTS_TABLE_NAME, + ], + multi: true, + }, + ], +}) +export class CoreXAPIModule {} From f1ac735abf2d2f0f8f08ca7b4a19151c2a697af4 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 16 Dec 2020 14:28:59 +0100 Subject: [PATCH 3/7] MOBILE-3666 h5p: Implement services and classes --- src/core/classes/sqlitedb.ts | 2 +- src/core/features/features.module.ts | 2 + .../features/h5p/classes/content-validator.ts | 1392 +++++++++++++++++ src/core/features/h5p/classes/core.ts | 1005 ++++++++++++ src/core/features/h5p/classes/file-storage.ts | 475 ++++++ src/core/features/h5p/classes/framework.ts | 917 +++++++++++ src/core/features/h5p/classes/helper.ts | 255 +++ src/core/features/h5p/classes/metadata.ts | 41 + src/core/features/h5p/classes/player.ts | 420 +++++ src/core/features/h5p/classes/storage.ts | 233 +++ src/core/features/h5p/classes/validator.ts | 328 ++++ src/core/features/h5p/h5p.module.ts | 51 + .../features/h5p/services/database/h5p.ts | 308 ++++ src/core/features/h5p/services/h5p.ts | 248 +++ src/core/services/file.ts | 43 +- src/core/services/ws.ts | 8 +- 16 files changed, 5712 insertions(+), 16 deletions(-) create mode 100644 src/core/features/h5p/classes/content-validator.ts create mode 100644 src/core/features/h5p/classes/core.ts create mode 100644 src/core/features/h5p/classes/file-storage.ts create mode 100644 src/core/features/h5p/classes/framework.ts create mode 100644 src/core/features/h5p/classes/helper.ts create mode 100644 src/core/features/h5p/classes/metadata.ts create mode 100644 src/core/features/h5p/classes/player.ts create mode 100644 src/core/features/h5p/classes/storage.ts create mode 100644 src/core/features/h5p/classes/validator.ts create mode 100644 src/core/features/h5p/h5p.module.ts create mode 100644 src/core/features/h5p/services/database/h5p.ts create mode 100644 src/core/features/h5p/services/h5p.ts diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 038b37967..5ee68de65 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -1087,7 +1087,7 @@ export class SQLiteDB { } export type SQLiteDBRecordValues = { - [key in string ]: SQLiteDBRecordValue | undefined; + [key in string ]: SQLiteDBRecordValue | undefined | null; }; export type SQLiteDBQueryParams = { diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index b3d5c06e5..57ed4343e 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -18,6 +18,7 @@ import { CoreCourseModule } from './course/course.module'; import { CoreCoursesModule } from './courses/courses.module'; import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreFileUploaderModule } from './fileuploader/fileuploader.module'; +import { CoreH5PModule } from './h5p/h5p.module'; import { CoreLoginModule } from './login/login.module'; import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; import { CoreSettingsModule } from './settings/settings.module'; @@ -41,6 +42,7 @@ import { CoreXAPIModule } from './xapi/xapi.module'; CoreUserModule, CorePushNotificationsModule, CoreXAPIModule, + CoreH5PModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/h5p/classes/content-validator.ts b/src/core/features/h5p/classes/content-validator.ts new file mode 100644 index 000000000..a65c8f4f3 --- /dev/null +++ b/src/core/features/h5p/classes/content-validator.ts @@ -0,0 +1,1392 @@ +// (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 { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { Translate } from '@singletons'; +import { CoreH5PCore, CoreH5PLibraryData, CoreH5PLibraryAddonData, CoreH5PContentDepsTreeDependency } from './core'; + +const ALLOWED_STYLEABLE_TAGS = ['span', 'p', 'div', 'h1', 'h2', 'h3', 'td']; + +/** + * Equivalent to H5P's H5PContentValidator, but without some of the validations. + * It's also used to build the dependency list. + */ +export class CoreH5PContentValidator { + + protected typeMap = { + text: 'validateText', + number: 'validateNumber', // eslint-disable-line id-blacklist + boolean: 'validateBoolean', // eslint-disable-line id-blacklist + list: 'validateList', + group: 'validateGroup', + file: 'validateFile', + image: 'validateImage', + video: 'validateVideo', + audio: 'validateAudio', + select: 'validateSelect', + library: 'validateLibrary', + }; + + protected nextWeight = 1; + protected libraries: {[libString: string]: CoreH5PLibraryData} = {}; + protected dependencies: {[key: string]: CoreH5PContentDepsTreeDependency} = {}; + protected relativePathRegExp = /^((\.\.\/){1,2})(.*content\/)?(\d+|editor)\/(.+)$/; + protected allowedHtml: {[tag: string]: string} = {}; + protected allowedStyles?: RegExp[]; + protected metadataSemantics?: CoreH5PSemantics[]; + protected copyrightSemantics?: CoreH5PSemantics; + + constructor(protected siteId: string) { } + + /** + * Add Addon library. + * + * @param library The addon library to add. + * @return Promise resolved when done. + */ + async addon(library: CoreH5PLibraryAddonData): Promise { + const depKey = 'preloaded-' + library.machineName; + + this.dependencies[depKey] = { + library: library, + type: 'preloaded', + }; + + this.nextWeight = await CoreH5P.instance.h5pCore.findLibraryDependencies(this.dependencies, library, this.nextWeight); + + this.dependencies[depKey].weight = this.nextWeight++; + } + + /** + * Get the flat dependency tree. + * + * @return Dependencies. + */ + getDependencies(): {[key: string]: CoreH5PContentDepsTreeDependency} { + return this.dependencies; + } + + /** + * Validate metadata + * + * @param metadata Metadata. + * @return Promise resolved with metadata validated & filtered. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateMetadata(metadata: any): Promise { + const semantics = this.getMetadataSemantics(); + const group = CoreUtils.instance.clone(metadata || {}); + + // Stop complaining about "invalid selected option in select" for old content without license chosen. + if (typeof group.license == 'undefined') { + group.license = 'U'; + } + + return this.validateGroup(group, { type: 'group', fields: semantics }, false); + } + + /** + * Validate given text value against text semantics. + * + * @param text Text to validate. + * @param semantics Semantics. + * @return Validated text. + */ + validateText(text: string, semantics: CoreH5PSemantics): string { + if (typeof text != 'string') { + text = ''; + } + + if (semantics.tags) { + // Not testing for empty array allows us to use the 4 defaults without specifying them in semantics. + let tags = ['div', 'span', 'p', 'br'].concat(semantics.tags); + + // Add related tags for table etc. + if (tags.indexOf('table') != -1) { + tags = tags.concat(['tr', 'td', 'th', 'colgroup', 'thead', 'tbody', 'tfoot']); + } + if (tags.indexOf('b') != -1) { + tags.push('strong'); + } + if (tags.indexOf('i') != -1) { + tags.push('em'); + } + if (tags.indexOf('ul') != -1 || tags.indexOf('ol') != -1) { + tags.push('li'); + } + if (tags.indexOf('del') != -1 || tags.indexOf('strike') != -1) { + tags.push('s'); + } + + tags = CoreUtils.instance.uniqueArray(tags); + + // Determine allowed style tags + const stylePatterns: RegExp[] = []; + // All styles must be start to end patterns (^...$) + if (semantics.font) { + if (semantics.font.size) { + stylePatterns.push(/^font-size: *[0-9.]+(em|px|%) *;?$/i); + } + if (semantics.font.family) { + stylePatterns.push(/^font-family: *[-a-z0-9," ]+;?$/i); + } + if (semantics.font.color) { + stylePatterns.push(/^color: *(#[a-f0-9]{3}[a-f0-9]{3}?|rgba?\([0-9, ]+\)) *;?$/i); + } + if (semantics.font.background) { + stylePatterns.push(/^background-color: *(#[a-f0-9]{3}[a-f0-9]{3}?|rgba?\([0-9, ]+\)) *;?$/i); + } + if (semantics.font.spacing) { + stylePatterns.push(/^letter-spacing: *[0-9.]+(em|px|%) *;?$/i); + } + if (semantics.font.height) { + stylePatterns.push(/^line-height: *[0-9.]+(em|px|%|) *;?$/i); + } + } + + // Alignment is allowed for all wysiwyg texts + stylePatterns.push(/^text-align: *(center|left|right);?$/i); + + // Strip invalid HTML tags. + text = this.filterXss(text, tags, stylePatterns); + } else { + // Filter text to plain text. + text = CoreTextUtils.instance.escapeHTML(text, false); + } + + // Check if string is within allowed length. + if (typeof semantics.maxLength != 'undefined') { + text = text.substr(0, semantics.maxLength); + } + + return text; + } + + /** + * Validates content files + * + * @param contentPath The path containing content files to validate. + * @param isLibrary Whether it's a library. + * @return True if all files are valid. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validateContentFiles(contentPath: string, isLibrary: boolean = false): boolean { + // Nothing to do, already checked by Moodle. + return true; + } + + /** + * Validate given value against number semantics. + * + * @param num Number to validate. + * @param semantics Semantics. + * @return Validated number. + */ + validateNumber(value: unknown, semantics: CoreH5PSemantics): number { + // Validate that num is indeed a number. + let num = Number(value); + if (isNaN(num)) { + num = 0; + } + // Check if number is within valid bounds. Move within bounds if not. + if (typeof semantics.min != 'undefined' && num < semantics.min) { + num = semantics.min; + } + if (typeof semantics.max != 'undefined' && num > semantics.max) { + num = semantics.max; + } + // Check if number is within allowed bounds even if step value is set. + if (typeof semantics.step != 'undefined') { + const testNumber = num - (typeof semantics.min != 'undefined' ? semantics.min : 0); + const rest = testNumber % semantics.step; + if (rest !== 0) { + num -= rest; + } + } + // Check if number has proper number of decimals. + if (typeof semantics.decimals != 'undefined') { + num = Number(num.toFixed(semantics.decimals)); + } + + return num; + } + + /** + * Validate given value against boolean semantics. + * + * @param bool Boolean to check. + * @return Validated bool. + */ + validateBoolean(bool: unknown): boolean { + return !!bool; + } + + /** + * Validate select values. + * + * @param select Values to validate. + * @param semantics Semantics. + * @return Validated select. + */ + validateSelect(select: string | string[], semantics: CoreH5PSemantics): string | string[] { + const optional = semantics.optional; + const options: Record = {}; + let strict = false; + + if (semantics.options && semantics.options.length) { + // We have a strict set of options to choose from. + strict = true; + + semantics.options.forEach((option: OptionSemantics) => { + // Support optgroup - just flatten options into one. + if (option.type == 'optgroup') { + option.options?.forEach((subOption) => { + options[subOption.value || ''] = true; + }); + } else if (option.value) { + options[option.value] = true; + } + }); + } + + if (semantics.multiple && Array.isArray(select)) { + // Multi-choice generates array of values. Test each one against valid options, if we are strict. + for (const key in select) { + const value = select[key]; + + if (strict && !optional && !options[value]) { + delete select[key]; + } else { + select[key] = CoreTextUtils.instance.escapeHTML(value, false); + } + } + } else { + // Single mode. If we get an array in here, we chop off the first element and use that instead. + if (Array.isArray(select)) { + select = select[0]; + } + + if (strict && !optional && !options[select]) { + select = ( semantics.options![0]).value || ''; + } + select = CoreTextUtils.instance.escapeHTML(select, false); + } + + return select; + } + + /** + * Validate given list value against list semantics. + * Will recurse into validating each item in the list according to the type. + * + * @param list List to validate. + * @param semantics Semantics. + * @return Validated list. + */ + async validateList(list: Record | unknown[], semantics: CoreH5PSemantics): Promise { + const field = semantics.field!; + const validateFunction = this[this.typeMap[field.type || '']].bind(this); + const isArray = Array.isArray(list); + let keys = Object.keys(list); + + // Check that list is not longer than allowed length. + if (typeof semantics.max != 'undefined') { + keys = keys.slice(0, semantics.max); + } + + // Validate each element in list. + for (const i in keys) { + const key = keys[i]; + const keyNumber = parseInt(key, 10); + + if (isNaN(keyNumber)) { + // It's an object and the key isn't an integer. Delete it. + delete list[key]; + } else { + const val = await validateFunction(list[keyNumber], field); + + if (val === null) { + if (isArray) { + ( list).splice(keyNumber, 1); + } else { + delete list[key]; + } + } else { + list[keyNumber] = val; + } + } + } + + if (!isArray) { + list = CoreUtils.instance.objectToArray(> list); + } + + if (!list.length) { + return null; + } + + return list; + } + + /** + * Validate a file like object, such as video, image, audio and file. + * + * @param file File to validate. + * @param semantics Semantics. + * @param typeValidKeys List of valid keys. + * @return Promise resolved with the validated file. + */ + protected async validateFilelike(file: FileLike, semantics: CoreH5PSemantics, typeValidKeys: string[] = []): Promise { + // Do not allow to use files from other content folders. + const matches = file.path.match(this.relativePathRegExp); + if (matches && matches.length) { + file.path = matches[5]; + } + + // Remove temporary files suffix. + if (file.path.substr(-4, 4) === '#tmp') { + file.path = file.path.substr(0, file.path.length - 4); + } + + // Make sure path and mime does not have any special chars + file.path = CoreTextUtils.instance.escapeHTML(file.path, false); + if (file.mime) { + file.mime = CoreTextUtils.instance.escapeHTML(file.mime, false); + } + + // Remove attributes that should not exist, they may contain JSON escape code. + let validKeys = ['path', 'mime', 'copyright'].concat(typeValidKeys); + if (semantics.extraAttributes) { + validKeys = validKeys.concat(semantics.extraAttributes); + } + validKeys = CoreUtils.instance.uniqueArray(validKeys); + + this.filterParams(file, validKeys); + + if (typeof file.width == 'string') { + file.width = parseInt(file.width, 10); + } + + if (typeof file.height == 'string') { + file.height = parseInt(file.height, 10); + } + + if (file.codecs) { + file.codecs = CoreTextUtils.instance.escapeHTML(file.codecs, false); + } + + if (typeof file.bitrate == 'string') { + file.bitrate = parseInt(file.bitrate, 10); + } + + if (typeof file.quality != 'undefined') { + if (file.quality === null || typeof file.quality.level == 'undefined' || typeof file.quality.label == 'undefined') { + delete file.quality; + } else { + this.filterParams(file.quality, ['level', 'label']); + file.quality.level = Number(file.quality.level); + file.quality.label = CoreTextUtils.instance.escapeHTML(file.quality.label, false); + } + } + + if (typeof file.copyright != 'undefined') { + await this.validateGroup(file.copyright, this.getCopyrightSemantics()); + } + + return file; + } + + /** + * Validate given file data. + * + * @param file File. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateFile(file: FileLike, semantics: CoreH5PSemantics): Promise { + return this.validateFilelike(file, semantics); + } + + /** + * Validate given image data. + * + * @param image Image. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateImage(image: FileLike, semantics: CoreH5PSemantics): Promise { + return this.validateFilelike(image, semantics, ['width', 'height', 'originalImage']); + } + + /** + * Validate given video data. + * + * @param video Video. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + async validateVideo(video: Record, semantics: CoreH5PSemantics): Promise> { + for (const key in video) { + await this.validateFilelike(video[key], semantics, ['width', 'height', 'codecs', 'quality', 'bitrate']); + } + + return video; + } + + /** + * Validate given audio data. + * + * @param audio Audio. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + async validateAudio(audio: Record, semantics: CoreH5PSemantics): Promise> { + for (const key in audio) { + await this.validateFilelike(audio[key], semantics); + } + + return audio; + } + + /** + * Validate given group value against group semantics. + * Will recurse into validating each group member. + * + * @param group Group. + * @param semantics Semantics. + * @param flatten Whether to flatten. + * @return Promise resolved when done. + */ + async validateGroup(group: unknown, semantics: CoreH5PSemantics, flatten: boolean = true): Promise { + if (!semantics.fields) { + return group; + } + + // Groups with just one field are compressed in the editor to only output the child content. + const isSubContent = semantics.isSubContent === true; + + if (semantics.fields.length == 1 && flatten && !isSubContent) { + const field = semantics.fields[0]; + const validateFunction = this[this.typeMap[field.type || '']].bind(this); + + return validateFunction(group, field); + } else { + const groupObject = > group; + + for (const key in groupObject) { + // If subContentId is set, keep value + if (isSubContent && key == 'subContentId') { + continue; + } + + // Find semantics for name=key. + let found = false; + let validateFunction: undefined | ((...args: unknown[]) => unknown); + let field: CoreH5PSemantics | undefined; + + for (let i = 0; i < semantics.fields.length; i++) { + field = semantics.fields[i]; + + if (field.name == key) { + if (semantics.optional) { + field.optional = true; + } + validateFunction = this[this.typeMap[field.type || '']].bind(this); + found = true; + break; + } + } + + if (found && validateFunction) { + const val = await validateFunction(groupObject[key], field); + + groupObject[key] = val; + if (val === null) { + delete groupObject[key]; + } + } else { + // Something exists in content that does not have a corresponding semantics field. Remove it. + delete groupObject.key; + } + } + + return groupObject; + } + } + + /** + * Validate given library value against library semantics. + * Check if provided library is within allowed options. + * Will recurse into validating the library's semantics too. + * + * @param value Value. + * @param semantics Semantics. + * @return Promise resolved when done. + */ + async validateLibrary(value: LibraryType, semantics: CoreH5PSemantics): Promise { + if (!value.library) { + return; + } + + if (!this.libraries[value.library]) { + // Load the library and store it in the index of libraries. + const libSpec = CoreH5PCore.libraryFromString(value.library); + + this.libraries[value.library] = await CoreH5P.instance.h5pCore.loadLibrary( + libSpec?.machineName || '', + libSpec?.majorVersion || 0, + libSpec?.minorVersion || 0, + this.siteId, + ); + } + + const library = this.libraries[value.library]; + + // Validate parameters. + value.params = await this.validateGroup(value.params, { type: 'group', fields: library.semantics }, false); + + // Validate subcontent's metadata + if (value.metadata) { + value.metadata = await this.validateMetadata(value.metadata); + } + + let validKeys = ['library', 'params', 'subContentId', 'metadata']; + if (semantics.extraAttributes) { + validKeys = CoreUtils.instance.uniqueArray(validKeys.concat(semantics.extraAttributes)); + } + + this.filterParams(value, validKeys); + + if (value.subContentId && + !value.subContentId.match(/^\{?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\}?$/)) { + delete value.subContentId; + } + + // Find all dependencies for this library. + const depKey = 'preloaded-' + library.machineName; + if (!this.dependencies[depKey]) { + this.dependencies[depKey] = { + library: library, + type: 'preloaded', + }; + + this.nextWeight = await CoreH5P.instance.h5pCore.findLibraryDependencies(this.dependencies, library, this.nextWeight); + + this.dependencies[depKey].weight = this.nextWeight++; + + return value; + } else { + return value; + } + } + + /** + * Check params for a whitelist of allowed properties. + * + * @param params Object to filter. + * @param whitelist List of keys to keep. + */ + filterParams(params: Record, whitelist: string[]): void { + for (const key in params) { + if (whitelist.indexOf(key) == -1) { + delete params[key]; + } + } + } + + /** + * Filters HTML to prevent cross-site-scripting (XSS) vulnerabilities. + * Based on kses by Ulf Harnhammar, see http://sourceforge.net/projects/kses. + * + * @param text The string with raw HTML in it. + * @param allowedTags An array of allowed tags. + * @param allowedStyles Allowed styles. + * @return An XSS safe version of the string. + */ + protected filterXss(text: string, allowedTags?: string[], allowedStyles?: RegExp[]): string { + if (!text || typeof text != 'string') { + return text; + } + + allowedTags = allowedTags || ['a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd']; + + this.allowedStyles = allowedStyles; + + // Store the text format. + this.filterXssSplit(allowedTags, true); + + // Remove Netscape 4 JS entities. + text = text.replace(/&\s*\{[^}]*(\}\s*;?|$)/g, ''); + + // Defuse all HTML entities. + text = text.replace(/&/g, '&'); + + // Change back only well-formed entities in our whitelist: + // Decimal numeric entities. + text = text.replace(/&#([0-9]+;)/g, '&#$1'); + // Hexadecimal numeric entities. + text = text.replace(/&#[Xx]0*((?:[0-9A-Fa-f]{2})+;)/g, '&#x$1'); + // Named entities. + text = text.replace(/&([A-Za-z][A-Za-z0-9]*;)/g, '&$1'); + + const matches = text.match(/(<(?=[^a-zA-Z!/])||<[^>]*(>|$)|>)/g); + if (matches && matches.length) { + matches.forEach((match) => { + text = text.replace(match, this.filterXssSplit([match])); + }); + } + + return text; + } + + /** + * Processes an HTML tag. + * + * @param tags An array with various meaning depending on the value of store. + * If store is TRUE then the array contains the allowed tags. + * If store is FALSE then the array has one element, the HTML tag to process. + * @param store Whether to store m. + * @return string If the element isn't allowed, an empty string. Otherwise, the cleaned up version of the HTML element. + */ + protected filterXssSplit(tags: string[], store: boolean = false): string { + if (store) { + this.allowedHtml = CoreUtils.instance.arrayToObject(tags); + + return ''; + } + + const tag = tags[0]; + + if (tag.substr(0, 1) != '<') { + // We matched a lone ">" character. + return '>'; + } else if (tag.length == 1) { + // We matched a lone "<" character. + return '<'; + } + + const matches = tag.match(/^<\s*(\/\s*)?([a-zA-Z0-9-]+)([^>]*)>?|()$/); + if (!matches) { + // Seriously malformed. + return ''; + } + + const slash = matches[1] ? matches[1].trim() : ''; + const attrList = matches[3] || ''; + const comment = matches[4] || ''; + let elem = matches[2] || ''; + + if (comment) { + elem = '!--'; + } + + if (!this.allowedHtml[elem.toLowerCase()]) { + // Disallowed HTML element. + return ''; + } + + if (comment) { + return comment; + } + + if (slash != '') { + return ''; + } + + // Is there a closing XHTML slash at the end of the attributes? + const newAttrList = attrList.replace(/(\s?)\/\s*$/g, '$1'); + const xhtmlSlash = attrList != newAttrList ? ' /' : ''; + + // Clean up attributes. + let attr2 = this.filterXssAttributes( + newAttrList, + ALLOWED_STYLEABLE_TAGS.indexOf(elem) != -1 ? this.allowedStyles : undefined, + ).join(' '); + attr2 = attr2.replace(/[<>]/g, ''); + attr2 = attr2.length ? ' ' + attr2 : ''; + + return '<' + elem + attr2 + xhtmlSlash + '>'; + } + + /** + * Processes a string of HTML attributes. + * + * @param attr HTML attributes. + * @param allowedStyles Allowed styles. + * @return Cleaned up version of the HTML attributes. + */ + protected filterXssAttributes(attr: string, allowedStyles?: RegExp[]): string[] { + const attrArray: string[] = []; + let mode = 0; + let attrName = ''; + let skip = false; + + while (attr.length != 0) { + // Was the last operation successful? + let working = 0; + let matches: RegExpMatchArray | null = null; + let thisVal: string | undefined; + + switch (mode) { + case 0: + // Attribute name, href for instance. + matches = attr.match(/^([-a-zA-Z]+)/); + if (matches && matches.length > 1) { + attrName = matches[1].toLowerCase(); + skip = (attrName == 'style' || attrName.substr(0, 2) == 'on'); + working = mode = 1; + attr = attr.replace(/^[-a-zA-Z]+/, ''); + } + break; + + case 1: + // Equals sign or valueless ("selected"). + if (attr.match(/^\s*=\s*/)) { + working = 1; + mode = 2; + attr = attr.replace(/^\s*=\s*/, ''); + break; + } + + if (attr.match(/^\s+/)) { + working = 1; + mode = 0; + if (!skip) { + attrArray.push(attrName); + } + attr = attr.replace(/^\s+/, ''); + } + break; + + case 2: + // Attribute value, a URL after href= for instance. + matches = attr.match(/^"([^"]*)"(\s+|$)/); + if (matches && matches.length > 1) { + if (allowedStyles && attrName === 'style') { + // Allow certain styles. + for (let i = 0; i < allowedStyles.length; i++) { + const pattern = allowedStyles[i]; + if (matches[1].match(pattern)) { + // All patterns are start to end patterns, and CKEditor adds one span per style. + attrArray.push('style="' + matches[1] + '"'); + break; + } + } + break; + } + + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArray.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^"[^"]*"(\s+|$)/, ''); + break; + } + + matches = attr.match(/^'([^']*)'(\s+|$)/); + if (matches && matches.length > 1) { + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArray.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^'[^']*'(\s+|$)/, ''); + break; + } + + matches = attr.match(/^([^\s"']+)(\s+|$)/); + if (matches && matches.length > 1) { + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArray.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^([^\s"']+)(\s+|$)/, ''); + } + break; + + default: + } + + if (working == 0) { + // Not well formed; remove and try again. + attr = attr.replace(/^("[^"]*("|$)|'[^']*('|$)||\S)*\s*/, ''); + mode = 0; + } + } + + // The attribute list ends with a valueless attribute like "selected". + if (mode == 1 && !skip) { + attrArray.push(attrName); + } + + return attrArray; + } + + /** + * Processes an HTML attribute value and strips dangerous protocols from URLs. + * + * @param str The string with the attribute value. + * @param decode Whether to decode entities in the str. + * @return Cleaned up and HTML-escaped version of str. + */ + filterXssBadProtocol(str: string, decode: boolean = true): string { + // Get the plain text representation of the attribute value (i.e. its meaning). + if (decode) { + str = CoreTextUtils.instance.decodeHTMLEntities(str); + } + + return CoreTextUtils.instance.escapeHTML(this.stripDangerousProtocols(str), false); + } + + /** + * Strips dangerous protocols (e.g. 'javascript:') from a URI. + * + * @param uri A plain-text URI that might contain dangerous protocols. + * @return A plain-text URI stripped of dangerous protocols. + */ + protected stripDangerousProtocols(uri: string): string { + + const allowedProtocols = { + ftp: true, + http: true, + https: true, + mailto: true, + }; + let before: string | undefined; + + // Iteratively remove any invalid protocol found. + do { + before = uri; + const colonPos = uri.indexOf(':'); + + if (colonPos > 0) { + // We found a colon, possibly a protocol. Verify. + const protocol = uri.substr(0, colonPos); + // If a colon is preceded by a slash, question mark or hash, it cannot possibly be part of the URL scheme. + // This must be a relative URL, which inherits the (safe) protocol of the base document. + if (protocol.match(/[/?#]/)) { + break; + } + // Check if this is a disallowed protocol. + if (!allowedProtocols[protocol.toLowerCase()]) { + uri = uri.substr(colonPos + 1); + } + } + } while (before != uri); + + return uri; + } + + /** + * Get metadata semantics. + * + * @return Semantics. + */ + getMetadataSemantics(): CoreH5PSemantics[] { + + if (this.metadataSemantics) { + return this.metadataSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.metadataSemantics = [ + { + name: 'title', + type: 'text', + label: Translate.instance.instant('core.h5p.title'), + placeholder: 'La Gioconda', + }, + { + name: 'license', + type: 'select', + label: Translate.instance.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: Translate.instance.instant('core.h5p.undisclosed'), + }, + { + type: 'optgroup', + label: Translate.instance.instant('core.h5p.creativecommons'), + options: [ + { + value: 'CC BY', + label: Translate.instance.instant('core.h5p.ccattribution'), + versions: ccVersions, + }, + { + value: 'CC BY-SA', + label: Translate.instance.instant('core.h5p.ccattributionsa'), + versions: ccVersions, + }, + { + value: 'CC BY-ND', + label: Translate.instance.instant('core.h5p.ccattributionnd'), + versions: ccVersions, + }, + { + value: 'CC BY-NC', + label: Translate.instance.instant('core.h5p.ccattributionnc'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-SA', + label: Translate.instance.instant('core.h5p.ccattributionncsa'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-ND', + label: Translate.instance.instant('core.h5p.ccattributionncnd'), + versions: ccVersions, + }, + { + value: 'CC0 1.0', + label: Translate.instance.instant('core.h5p.ccpdd'), + }, + { + value: 'CC PDM', + label: Translate.instance.instant('core.h5p.pdm'), + }, + ], + }, + { + value: 'GNU GPL', + label: Translate.instance.instant('core.h5p.gpl'), + }, + { + value: 'PD', + label: Translate.instance.instant('core.h5p.pd'), + }, + { + value: 'ODC PDDL', + label: Translate.instance.instant('core.h5p.pddl'), + }, + { + value: 'C', + label: Translate.instance.instant('core.h5p.copyrightstring'), + }, + ], + }, + { + name: 'licenseVersion', + type: 'select', + label: Translate.instance.instant('core.h5p.licenseversion'), + options: ccVersions, + optional: true, + }, + { + name: 'yearFrom', + type: 'number', + label: Translate.instance.instant('core.h5p.yearsfrom'), + placeholder: '1991', + min: -9999, + max: 9999, + optional: true, + }, + { + name: 'yearTo', + type: 'number', + label: Translate.instance.instant('core.h5p.yearsto'), + placeholder: '1992', + min: -9999, + max: 9999, + optional: true, + }, + { + name: 'source', + type: 'text', + label: Translate.instance.instant('core.h5p.source'), + placeholder: 'https://', + optional: true, + }, + { + name: 'authors', + type: 'list', + field: { + name: 'author', + type: 'group', + fields: [ + { + label: Translate.instance.instant('core.h5p.authorname'), + name: 'name', + optional: true, + type: 'text', + }, + { + name: 'role', + type: 'select', + label: Translate.instance.instant('core.h5p.authorrole'), + default: 'Author', + options: [ + { + value: 'Author', + label: Translate.instance.instant('core.h5p.author'), + }, + { + value: 'Editor', + label: Translate.instance.instant('core.h5p.editor'), + }, + { + value: 'Licensee', + label: Translate.instance.instant('core.h5p.licensee'), + }, + { + value: 'Originator', + label: Translate.instance.instant('core.h5p.originator'), + }, + ], + }, + ], + }, + }, + { + name: 'licenseExtras', + type: 'text', + widget: 'textarea', + label: Translate.instance.instant('core.h5p.licenseextras'), + optional: true, + description: Translate.instance.instant('core.h5p.additionallicenseinfo'), + }, + { + name: 'changes', + type: 'list', + field: { + name: 'change', + type: 'group', + label: Translate.instance.instant('core.h5p.changelog'), + fields: [ + { + name: 'date', + type: 'text', + label: Translate.instance.instant('core.h5p.date'), + optional: true, + }, + { + name: 'author', + type: 'text', + label: Translate.instance.instant('core.h5p.changedby'), + optional: true, + }, + { + name: 'log', + type: 'text', + widget: 'textarea', + label: Translate.instance.instant('core.h5p.changedescription'), + placeholder: Translate.instance.instant('core.h5p.changeplaceholder'), + optional: true, + }, + ], + }, + }, + { + name: 'authorComments', + type: 'text', + widget: 'textarea', + label: Translate.instance.instant('core.h5p.authorcomments'), + description: Translate.instance.instant('core.h5p.authorcommentsdescription'), + optional: true, + }, + { + name: 'contentType', + type: 'text', + widget: 'none', + }, + { + name: 'defaultLanguage', + type: 'text', + widget: 'none', + }, + ]; + + return this.metadataSemantics!; + } + + /** + * Get copyright semantics. + * + * @return Semantics. + */ + getCopyrightSemantics(): CoreH5PSemantics { + + if (this.copyrightSemantics) { + return this.copyrightSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.copyrightSemantics = { + name: 'copyright', + type: 'group', + label: Translate.instance.instant('core.h5p.copyrightinfo'), + fields: [ + { + name: 'title', + type: 'text', + label: Translate.instance.instant('core.h5p.title'), + placeholder: 'La Gioconda', + optional: true, + }, + { + name: 'author', + type: 'text', + label: Translate.instance.instant('core.h5p.author'), + placeholder: 'Leonardo da Vinci', + optional: true, + }, + { + name: 'year', + type: 'text', + label: Translate.instance.instant('core.h5p.years'), + placeholder: '1503 - 1517', + optional: true, + }, + { + name: 'source', + type: 'text', + label: Translate.instance.instant('core.h5p.source'), + placeholder: 'http://en.wikipedia.org/wiki/Mona_Lisa', + optional: true, + regexp: { + pattern: '^http[s]?://.+', + modifiers: 'i', + }, + }, + { + name: 'license', + type: 'select', + label: Translate.instance.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: Translate.instance.instant('core.h5p.undisclosed'), + }, + { + value: 'CC BY', + label: Translate.instance.instant('core.h5p.ccattribution'), + versions: ccVersions, + }, + { + value: 'CC BY-SA', + label: Translate.instance.instant('core.h5p.ccattributionsa'), + versions: ccVersions, + }, + { + value: 'CC BY-ND', + label: Translate.instance.instant('core.h5p.ccattributionnd'), + versions: ccVersions, + }, + { + value: 'CC BY-NC', + label: Translate.instance.instant('core.h5p.ccattributionnc'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-SA', + label: Translate.instance.instant('core.h5p.ccattributionncsa'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-ND', + label: Translate.instance.instant('core.h5p.ccattributionncnd'), + versions: ccVersions, + }, + { + value: 'GNU GPL', + label: Translate.instance.instant('core.h5p.licenseGPL'), + versions: [ + { + value: 'v3', + label: Translate.instance.instant('core.h5p.licenseV3'), + }, + { + value: 'v2', + label: Translate.instance.instant('core.h5p.licenseV2'), + }, + { + value: 'v1', + label: Translate.instance.instant('core.h5p.licenseV1'), + }, + ], + }, + { + value: 'PD', + label: Translate.instance.instant('core.h5p.pd'), + versions: [ + { + value: '-', + label: '-', + }, + { + value: 'CC0 1.0', + label: Translate.instance.instant('core.h5p.licenseCC010U'), + }, + { + value: 'CC PDM', + label: Translate.instance.instant('core.h5p.pdm'), + }, + ], + }, + { + value: 'C', + label: Translate.instance.instant('core.h5p.copyrightstring'), + }, + ], + }, + { + name: 'version', + type: 'select', + label: Translate.instance.instant('core.h5p.licenseversion'), + options: [], + }, + ], + }; + + return this.copyrightSemantics!; + } + + /** + * Get CC versions for semantics. + * + * @return CC versions. + */ + protected getCCVersions(): VersionSemantics[] { + return [ + { + value: '4.0', + label: Translate.instance.instant('core.h5p.licenseCC40'), + }, + { + value: '3.0', + label: Translate.instance.instant('core.h5p.licenseCC30'), + }, + { + value: '2.5', + label: Translate.instance.instant('core.h5p.licenseCC25'), + }, + { + value: '2.0', + label: Translate.instance.instant('core.h5p.licenseCC20'), + }, + { + value: '1.0', + label: Translate.instance.instant('core.h5p.licenseCC10'), + }, + ]; + } + +} + +/** + * Semantics of each field type. More info in https://h5p.org/semantics + */ +export type CoreH5PSemantics = { + type?: string; + name?: string; + label?: string; + description?: string; + optional?: boolean; + default?: string; + importance?: 'low' | 'medium' | 'high'; + common?: boolean; + widget?: string; + widgets?: { + name: string; + label: string; + }[]; + field?: CoreH5PSemantics; + fields?: CoreH5PSemantics[]; + maxLength?: number; + regexp?: { + pattern: string; + modifiers: string; + }; + enterMode?: 'p' | 'div'; + tags?: string[]; + font?: { + size?: unknown; + family?: unknown; + color?: unknown; + background?: unknown; + spacing?: unknown; + height?: unknown; + }; + min?: number; + max?: number; + step?: number; + decimals?: number; + entity?: string; + isSubContent?: boolean; + expanded?: boolean; + options?: (string | OptionSemantics)[]; + important?: { + description: string; + example: string; + }; + multiple?: boolean; + extraAttributes?: string[]; + placeholder?: string; +}; + +type OptionSemantics = { + value?: string; + label?: string; + type?: string; + options?: OptionSemantics[]; + versions?: VersionSemantics[]; +}; + +type VersionSemantics = { + value: string; + label: string; +}; + +/** + * File like object, such as video, image, audio and file. + */ +type FileLike = { + path: string; + mime?: string; + width?: number | string; + height?: number | string; + codecs?: string; + bitrate?: number | string; + quality?: { + level?: string | number; + label?: string; + }; + copyright?: unknown; +}; + +/** + * Library type. + */ +type LibraryType = { + library: string; + params: unknown; + metadata?: unknown; + subContentId?: string; +}; diff --git a/src/core/features/h5p/classes/core.ts b/src/core/features/h5p/classes/core.ts new file mode 100644 index 000000000..c0d65a380 --- /dev/null +++ b/src/core/features/h5p/classes/core.ts @@ -0,0 +1,1005 @@ +// (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 { Md5 } from 'ts-md5/dist/md5'; + +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreH5PFileStorage } from './file-storage'; +import { CoreH5PFramework } from './framework'; +import { CoreH5PContentValidator, CoreH5PSemantics } from './content-validator'; +import { Translate } from '@singletons'; +import { CoreH5PContentBeingSaved } from './storage'; +import { CoreH5PLibraryAddTo } from './validator'; + +/** + * Equivalent to H5P's H5PCore class. + */ +export class CoreH5PCore { + + static readonly STYLES = [ + 'styles/h5p.css', + 'styles/h5p-confirmation-dialog.css', + 'styles/h5p-core-button.css', + ]; + + static readonly SCRIPTS = [ + 'js/jquery.js', + 'js/h5p.js', + 'js/h5p-event-dispatcher.js', + 'js/h5p-x-api-event.js', + 'js/h5p-x-api.js', + 'js/h5p-content-type.js', + 'js/h5p-confirmation-dialog.js', + 'js/h5p-action-bar.js', + 'js/request-queue.js', + ]; + + static readonly ADMIN_SCRIPTS = [ + 'js/jquery.js', + 'js/h5p-utils.js', + ]; + + // Disable flags + static readonly DISABLE_NONE = 0; + static readonly DISABLE_FRAME = 1; + static readonly DISABLE_DOWNLOAD = 2; + static readonly DISABLE_EMBED = 4; + static readonly DISABLE_COPYRIGHT = 8; + static readonly DISABLE_ABOUT = 16; + + static readonly DISPLAY_OPTION_FRAME = 'frame'; + static readonly DISPLAY_OPTION_DOWNLOAD = 'export'; + static readonly DISPLAY_OPTION_EMBED = 'embed'; + static readonly DISPLAY_OPTION_COPYRIGHT = 'copyright'; + static readonly DISPLAY_OPTION_ABOUT = 'icon'; + static readonly DISPLAY_OPTION_COPY = 'copy'; + + // Map to slugify characters. + static readonly SLUGIFY_MAP = { + // eslint-disable-next-line @typescript-eslint/naming-convention + æ: 'ae', ø: 'oe', ö: 'o', ó: 'o', ô: 'o', Ò: 'oe', Õ: 'o', Ý: 'o', ý: 'y', ÿ: 'y', ā: 'y', ă: 'a', ą: 'a', œ: 'a', å: 'a', + ä: 'a', á: 'a', à: 'a', â: 'a', ã: 'a', ç: 'c', ć: 'c', ĉ: 'c', ċ: 'c', č: 'c', é: 'e', è: 'e', ê: 'e', ë: 'e', í: 'i', + ì: 'i', î: 'i', ï: 'i', ú: 'u', ñ: 'n', ü: 'u', ù: 'u', û: 'u', ß: 'es', ď: 'd', đ: 'd', ē: 'e', ĕ: 'e', ė: 'e', ę: 'e', + ě: 'e', ĝ: 'g', ğ: 'g', ġ: 'g', ģ: 'g', ĥ: 'h', ħ: 'h', ĩ: 'i', ī: 'i', ĭ: 'i', į: 'i', ı: 'i', ij: 'ij', ĵ: 'j', ķ: 'k', + ĺ: 'l', ļ: 'l', ľ: 'l', ŀ: 'l', ł: 'l', ń: 'n', ņ: 'n', ň: 'n', ʼn: 'n', ō: 'o', ŏ: 'o', ő: 'o', ŕ: 'r', ŗ: 'r', ř: 'r', + ś: 's', ŝ: 's', ş: 's', š: 's', ţ: 't', ť: 't', ŧ: 't', ũ: 'u', ū: 'u', ŭ: 'u', ů: 'u', ű: 'u', ų: 'u', ŵ: 'w', ŷ: 'y', + ź: 'z', ż: 'z', ž: 'z', ſ: 's', ƒ: 'f', ơ: 'o', ư: 'u', ǎ: 'a', ǐ: 'i', ǒ: 'o', ǔ: 'u', ǖ: 'u', ǘ: 'u', ǚ: 'u', ǜ: 'u', + ǻ: 'a', ǽ: 'ae', ǿ: 'oe', + }; + + aggregateAssets = true; + h5pFS: CoreH5PFileStorage; + + constructor(public h5pFramework: CoreH5PFramework) { + this.h5pFS = new CoreH5PFileStorage(); + } + + /** + * Determine the correct embed type to use. + * + * @param Embed type of the content. + * @param Embed type of the main library. + * @return Either 'div' or 'iframe'. + */ + static determineEmbedType(contentEmbedType: string, libraryEmbedTypes: string): string { + // Detect content embed type. + let embedType = contentEmbedType.toLowerCase().indexOf('div') != -1 ? 'div' : 'iframe'; + + if (libraryEmbedTypes) { + // Check that embed type is available for library + const embedTypes = libraryEmbedTypes.toLowerCase(); + + if (embedTypes.indexOf(embedType) == -1) { + // Not available, pick default. + embedType = embedTypes.indexOf('div') != -1 ? 'div' : 'iframe'; + } + } + + return embedType; + } + + /** + * Get the hash of a list of dependencies. + * + * @param dependencies Dependencies. + * @return Hash. + */ + static getDependenciesHash(dependencies: {[machineName: string]: CoreH5PContentDependencyData}): string { + // Build hash of dependencies. + const toHash: string[] = []; + + // Use unique identifier for each library version. + for (const name in dependencies) { + const dep = dependencies[name]; + toHash.push(dep.machineName + '-' + dep.majorVersion + '.' + dep.minorVersion + '.' + dep.patchVersion); + } + + // Sort in case the same dependencies comes in a different order. + toHash.sort((a, b) => a.localeCompare(b)); + + // Calculate hash. + return Md5.hashAsciiStr(toHash.join('')); + } + + /** + * Get core JavaScript files. + * + * @return array The array containg urls of the core JavaScript files: + */ + static getScripts(): string[] { + const libUrl = CoreH5P.instance.h5pCore.h5pFS.getCoreH5PPath(); + const urls: string[] = []; + + CoreH5PCore.SCRIPTS.forEach((script) => { + urls.push(libUrl + script); + }); + + urls.push(CoreTextUtils.instance.concatenatePaths(libUrl, 'moodle/js/h5p_overrides.js')); + + return urls; + } + + /** + * Parses library data from a string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryString On the form {machineName} {majorVersion}.{minorVersion} + * @return Object with keys machineName, majorVersion and minorVersion. Null if string is not parsable. + */ + static libraryFromString(libraryString: string): CoreH5PLibraryBasicData | null { + + const matches = libraryString.match(/^([\w0-9\-.]{1,255})[- ]([0-9]{1,5})\.([0-9]{1,5})$/i); + + if (matches && matches.length >= 4) { + return { + machineName: matches[1], + majorVersion: Number(matches[2]), + minorVersion: Number(matches[3]), + }; + } + + return null; + } + + /** + * Writes library data as string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryData Library data. + * @param folderName Use hyphen instead of space in returned string. + * @return String on the form {machineName} {majorVersion}.{minorVersion}. + */ + static libraryToString(libraryData: CoreH5PLibraryBasicData | CoreH5PContentMainLibraryData, folderName?: boolean): string { + return ('machineName' in libraryData ? libraryData.machineName : libraryData.name) + (folderName ? '-' : ' ') + + libraryData.majorVersion + '.' + libraryData.minorVersion; + } + + /** + * Convert strings of text into simple kebab case slugs. Based on H5PCore::slugify. + * + * @param input The string to slugify. + * @return Slugified text. + */ + static slugify(input: string): string { + input = input || ''; + + input = input.toLowerCase(); + + // Replace common chars. + let newInput = ''; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + newInput += CoreH5PCore.SLUGIFY_MAP[char] || char; + } + + // Replace everything else. + newInput = newInput.replace(/[^a-z0-9]/g, '-'); + + // Prevent double hyphen + newInput = newInput.replace(/-{2,}/g, '-'); + + // Prevent hyphen in beginning or end. + newInput = newInput.replace(/(^-+|-+$)/g, ''); + + // Prevent too long slug. + if (newInput.length > 91) { + newInput = newInput.substr(0, 92); + } + + // Prevent empty slug + if (newInput === '') { + newInput = 'interactive'; + } + + return newInput; + } + + /** + * Filter content run parameters and rebuild content dependency cache. + * + * @param content Content data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filtered params, resolved with null if error. + */ + async filterParameters(content: CoreH5PContentData, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (content.filtered) { + return content.filtered; + } + + if (typeof content.library == 'undefined' || typeof content.params == 'undefined') { + return null; + } + + const params = { + library: CoreH5PCore.libraryToString(content.library), + params: CoreTextUtils.instance.parseJSON(content.params, false), + }; + + if (!params.params) { + return null; + } + + try { + const validator = new CoreH5PContentValidator(siteId); + + // Validate the main library and its dependencies. + await validator.validateLibrary(params, { options: [params.library] }); + + // Handle addons. + const addons = await this.h5pFramework.loadAddons(siteId); + + // Validate addons. + for (const i in addons) { + const addon = addons[i]; + + if (addon.addTo?.content?.types?.length) { + for (let i = 0; i < addon.addTo.content.types.length; i++) { + const type = addon.addTo.content.types[i]; + + if (type && type.text && type.text.regex && this.textAddonMatches(params.params, type.text.regex)) { + await validator.addon(addon); + + // An addon shall only be added once. + break; + } + } + } + } + + // Update content dependencies. + content.dependencies = validator.getDependencies(); + + const paramsStr = JSON.stringify(params.params); + + // Sometimes the parameters are filtered before content has been created + if (content.id) { + // Update library usage. + try { + await this.h5pFramework.deleteLibraryUsage(content.id, siteId); + } catch (error) { + // Ignore errors. + } + + await this.h5pFramework.saveLibraryUsage(content.id, content.dependencies, siteId); + + if (!content.slug) { + content.slug = this.generateContentSlug(content); + } + + // Cache. + await this.h5pFramework.updateContentFields(content.id, { + filtered: paramsStr, + }, siteId); + } + + return paramsStr; + } catch (error) { + return null; + } + } + + /** + * Recursive. Goes through the dependency tree for the given library and + * adds all the dependencies to the given array in a flat format. + * + * @param dependencies Object where to save the dependencies. + * @param library The library to find all dependencies for. + * @param nextWeight An integer determining the order of the libraries when they are loaded. + * @param editor Used internally to force all preloaded sub dependencies of an editor dependency to be editor dependencies. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the next weight. + */ + async findLibraryDependencies( + dependencies: {[key: string]: CoreH5PContentDepsTreeDependency}, + library: CoreH5PLibraryData | CoreH5PLibraryAddonData, + nextWeight: number = 1, + editor: boolean = false, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const types = ['dynamic', 'preloaded', 'editor']; + + for (const i in types) { + let type = types[i]; + const property = type + 'Dependencies'; + + if (!library[property]) { + continue; // Skip, no such dependencies. + } + + if (type === 'preloaded' && editor) { + // All preloaded dependencies of an editor library is set to editor. + type = 'editor'; + } + + for (const j in library[property]) { + const dependency: CoreH5PLibraryBasicData = library[property][j]; + + const dependencyKey = type + '-' + dependency.machineName; + if (dependencies[dependencyKey]) { + continue; // Skip, already have this. + } + + // Get the dependency library data and its subdependencies. + const dependencyLibrary = await this.loadLibrary( + dependency.machineName, + dependency.majorVersion, + dependency.minorVersion, + siteId, + ); + + dependencies[dependencyKey] = { + library: dependencyLibrary, + type: type, + }; + + // Get all its subdependencies. + const weight = await this.findLibraryDependencies( + dependencies, + dependencyLibrary, + nextWeight, + type === 'editor', + siteId, + ); + + nextWeight = weight; + dependencies[dependencyKey].weight = nextWeight++; + } + } + + return nextWeight; + } + + /** + * Validate and fix display options, updating them if needed. + * + * @param displayOptions The display options to validate. + * @param id Package ID. + */ + fixDisplayOptions(displayOptions: CoreH5PDisplayOptions, id: number): CoreH5PDisplayOptions { + displayOptions = displayOptions || {}; + + // Never allow downloading in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = false; + + // Never show the embed option in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] = false; + + if (!this.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_FRAME, true)) { + displayOptions[CoreH5PCore.DISPLAY_OPTION_FRAME] = false; + } else if (this.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_COPYRIGHT, true) == false) { + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = false; + } + + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPY] = this.h5pFramework.hasPermission(CoreH5PPermission.COPY_H5P, id); + + return displayOptions; + } + + /** + * Parses library data from a string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryString On the form {machineName} {majorVersion}.{minorVersion} + * @return Object with keys machineName, majorVersion and minorVersion. Null if string is not parsable. + */ + generateContentSlug(content: CoreH5PContentData): string { + + let slug = CoreH5PCore.slugify(content.title); + let available: boolean | null = null; + + while (!available) { + if (available === false) { + // If not available, add number suffix. + const matches = slug.match(/(.+-)([0-9]+)$/); + if (matches) { + slug = matches[1] + (Number(matches[2]) + 1); + } else { + slug += '-2'; + } + } + + available = this.h5pFramework.isContentSlugAvailable(slug); + } + + return slug; + } + + /** + * Combines path with version. + * + * @param assets List of assets to get their URLs. + * @param assetsFolderPath The path of the folder where the assets are. + * @return List of urls. + */ + getAssetsUrls(assets: CoreH5PDependencyAsset[], assetsFolderPath: string = ''): string[] { + const urls: string[] = []; + + assets.forEach((asset) => { + let url = asset.path; + + // Add URL prefix if not external. + if (asset.path.indexOf('://') == -1 && assetsFolderPath) { + url = CoreTextUtils.instance.concatenatePaths(assetsFolderPath, url); + } + + // Add version if set. + if (asset.version) { + url += asset.version; + } + + urls.push(url); + }); + + return urls; + } + + /** + * Return file paths for all dependencies files. + * + * @param dependencies The dependencies to get the files. + * @param folderName Name of the folder of the content. + * @param prefix Make paths relative to another dir. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getDependenciesFiles( + dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + folderName: string, + prefix: string = '', + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Build files list for assets. + const files: CoreH5PDependenciesFiles = { + scripts: [], + styles: [], + }; + + // Avoid caching empty files. + if (!Object.keys(dependencies).length) { + return files; + } + + let cachedAssetsHash: string; + + if (this.aggregateAssets) { + // Get aggregated files for assets. + cachedAssetsHash = CoreH5PCore.getDependenciesHash(dependencies); + + const cachedAssets = await this.h5pFS.getCachedAssets(cachedAssetsHash); + + if (cachedAssets) { + // Cached assets found, return them. + return Object.assign(files, cachedAssets); + } + } + + // No cached assets, use content dependencies. + for (const key in dependencies) { + const dependency = dependencies[key]; + + if (!dependency.path) { + dependency.path = this.h5pFS.getDependencyPath(dependency); + dependency.preloadedJs = ( dependency.preloadedJs).split(','); + dependency.preloadedCss = ( dependency.preloadedCss).split(','); + } + + dependency.version = '?ver=' + dependency.majorVersion + '.' + dependency.minorVersion + '.' + dependency.patchVersion; + + this.getDependencyAssets(dependency, 'preloadedJs', files.scripts, prefix); + this.getDependencyAssets(dependency, 'preloadedCss', files.styles, prefix); + } + + if (this.aggregateAssets) { + // Aggregate and store assets. + await this.h5pFS.cacheAssets(files, cachedAssetsHash!, folderName, siteId); + + // Keep track of which libraries have been cached in case they are updated. + await this.h5pFramework.saveCachedAssets(cachedAssetsHash!, dependencies, folderName, siteId); + } + + return files; + } + + /** + * Get the paths to the content dependencies. + * + * @param id The H5P content ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with an object containing the path of each content dependency. + */ + async getDependencyRoots(id: number, siteId?: string): Promise<{[libString: string]: string}> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const roots = {}; + + const dependencies = await this.h5pFramework.loadContentDependencies(id, undefined, siteId); + + for (const machineName in dependencies) { + const dependency = dependencies[machineName]; + const folderName = CoreH5PCore.libraryToString(dependency, true); + + roots[folderName] = this.h5pFS.getLibraryFolderPath(dependency, siteId, folderName); + } + + return roots; + } + + /** + * Get all dependency assets of the given type. + * + * @param dependency The dependency. + * @param type Type of assets to get. + * @param assets Array where to store the assets. + * @param prefix Make paths relative to another dir. + */ + protected getDependencyAssets( + dependency: CoreH5PContentDependencyData, + type: string, + assets: CoreH5PDependencyAsset[], + prefix: string = '', + ): void { + + // Check if dependency has any files of this type + if (!dependency[type] || dependency[type][0] === '') { + return; + } + + // Check if we should skip CSS. + if (type === 'preloadedCss' && CoreUtils.instance.isTrueOrOne(dependency.dropCss)) { + return; + } + + for (const key in dependency[type]) { + const file = dependency[type][key]; + + assets.push({ + path: prefix + '/' + dependency.path + '/' + (typeof file != 'string' ? file.path : file).trim(), + version: dependency.version || '', + }); + } + } + + /** + * Convert display options to an object. + * + * @param disable Display options as a number. + * @return Display options as object. + */ + getDisplayOptionsAsObject(disable: number): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + // eslint-disable-next-line no-bitwise + displayOptions[CoreH5PCore.DISPLAY_OPTION_FRAME] = !(disable & CoreH5PCore.DISABLE_FRAME); + displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = false; // Never allow downloading in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] = false; // Never show the embed option in the app. + // eslint-disable-next-line no-bitwise + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = !(disable & CoreH5PCore.DISABLE_COPYRIGHT); + displayOptions[CoreH5PCore.DISPLAY_OPTION_ABOUT] = !!this.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Determine display option visibility when viewing H5P + * + * @param disable The display options as a number. + * @param id Package ID. + * @return Display options as object. + */ + getDisplayOptionsForView(disable: number, id: number): CoreH5PDisplayOptions { + return this.fixDisplayOptions(this.getDisplayOptionsAsObject(disable), id); + } + + /** + * Provide localization for the Core JS. + * + * @return Object with the translations. + */ + getLocalization(): {[name: string]: string} { + return { + fullscreen: Translate.instance.instant('core.h5p.fullscreen'), + disableFullscreen: Translate.instance.instant('core.h5p.disablefullscreen'), + download: Translate.instance.instant('core.h5p.download'), + copyrights: Translate.instance.instant('core.h5p.copyright'), + embed: Translate.instance.instant('core.h5p.embed'), + size: Translate.instance.instant('core.h5p.size'), + showAdvanced: Translate.instance.instant('core.h5p.showadvanced'), + hideAdvanced: Translate.instance.instant('core.h5p.hideadvanced'), + advancedHelp: Translate.instance.instant('core.h5p.resizescript'), + copyrightInformation: Translate.instance.instant('core.h5p.copyright'), + close: Translate.instance.instant('core.h5p.close'), + title: Translate.instance.instant('core.h5p.title'), + author: Translate.instance.instant('core.h5p.author'), + year: Translate.instance.instant('core.h5p.year'), + source: Translate.instance.instant('core.h5p.source'), + license: Translate.instance.instant('core.h5p.license'), + thumbnail: Translate.instance.instant('core.h5p.thumbnail'), + noCopyrights: Translate.instance.instant('core.h5p.nocopyright'), + reuse: Translate.instance.instant('core.h5p.reuse'), + reuseContent: Translate.instance.instant('core.h5p.reuseContent'), + reuseDescription: Translate.instance.instant('core.h5p.reuseDescription'), + downloadDescription: Translate.instance.instant('core.h5p.downloadtitle'), + copyrightsDescription: Translate.instance.instant('core.h5p.copyrighttitle'), + embedDescription: Translate.instance.instant('core.h5p.embedtitle'), + h5pDescription: Translate.instance.instant('core.h5p.h5ptitle'), + contentChanged: Translate.instance.instant('core.h5p.contentchanged'), + startingOver: Translate.instance.instant('core.h5p.startingover'), + by: Translate.instance.instant('core.h5p.by'), + showMore: Translate.instance.instant('core.h5p.showmore'), + showLess: Translate.instance.instant('core.h5p.showless'), + subLevel: Translate.instance.instant('core.h5p.sublevel'), + confirmDialogHeader: Translate.instance.instant('core.h5p.confirmdialogheader'), + confirmDialogBody: Translate.instance.instant('core.h5p.confirmdialogbody'), + cancelLabel: Translate.instance.instant('core.h5p.cancellabel'), + confirmLabel: Translate.instance.instant('core.h5p.confirmlabel'), + licenseU: Translate.instance.instant('core.h5p.undisclosed'), + licenseCCBY: Translate.instance.instant('core.h5p.ccattribution'), + licenseCCBYSA: Translate.instance.instant('core.h5p.ccattributionsa'), + licenseCCBYND: Translate.instance.instant('core.h5p.ccattributionnd'), + licenseCCBYNC: Translate.instance.instant('core.h5p.ccattributionnc'), + licenseCCBYNCSA: Translate.instance.instant('core.h5p.ccattributionncsa'), + licenseCCBYNCND: Translate.instance.instant('core.h5p.ccattributionncnd'), + licenseCC40: Translate.instance.instant('core.h5p.licenseCC40'), + licenseCC30: Translate.instance.instant('core.h5p.licenseCC30'), + licenseCC25: Translate.instance.instant('core.h5p.licenseCC25'), + licenseCC20: Translate.instance.instant('core.h5p.licenseCC20'), + licenseCC10: Translate.instance.instant('core.h5p.licenseCC10'), + licenseGPL: Translate.instance.instant('core.h5p.licenseGPL'), + licenseV3: Translate.instance.instant('core.h5p.licenseV3'), + licenseV2: Translate.instance.instant('core.h5p.licenseV2'), + licenseV1: Translate.instance.instant('core.h5p.licenseV1'), + licensePD: Translate.instance.instant('core.h5p.pd'), + licenseCC010: Translate.instance.instant('core.h5p.licenseCC010'), + licensePDM: Translate.instance.instant('core.h5p.pdm'), + licenseC: Translate.instance.instant('core.h5p.copyrightstring'), + contentType: Translate.instance.instant('core.h5p.contenttype'), + licenseExtras: Translate.instance.instant('core.h5p.licenseextras'), + changes: Translate.instance.instant('core.h5p.changelog'), + contentCopied: Translate.instance.instant('core.h5p.contentCopied'), + connectionLost: Translate.instance.instant('core.h5p.connectionLost'), + connectionReestablished: Translate.instance.instant('core.h5p.connectionReestablished'), + resubmitScores: Translate.instance.instant('core.h5p.resubmitScores'), + offlineDialogHeader: Translate.instance.instant('core.h5p.offlineDialogHeader'), + offlineDialogBody: Translate.instance.instant('core.h5p.offlineDialogBody'), + offlineDialogRetryMessage: Translate.instance.instant('core.h5p.offlineDialogRetryMessage'), + offlineDialogRetryButtonLabel: Translate.instance.instant('core.h5p.offlineDialogRetryButtonLabel'), + offlineSuccessfulSubmit: Translate.instance.instant('core.h5p.offlineSuccessfulSubmit'), + }; + } + + /** + * Load content data from DB. + * + * @param id Content ID. + * @param fileUrl H5P file URL. Required if id is not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async loadContent(id?: number, fileUrl?: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const content = await this.h5pFramework.loadContent(id, fileUrl, siteId); + + // Validate metadata. + const validator = new CoreH5PContentValidator(siteId); + + content.metadata = await validator.validateMetadata(content.metadata); + + return { + id: content.id, + params: content.params, + embedType: content.embedType, + disable: content.disable, + folderName: content.folderName, + title: content.title, + slug: content.slug, + filtered: content.filtered, + libraryMajorVersion: content.libraryMajorVersion, + libraryMinorVersion: content.libraryMinorVersion, + metadata: content.metadata, + library: { + id: content.libraryId, + name: content.libraryName, + majorVersion: content.libraryMajorVersion, + minorVersion: content.libraryMinorVersion, + embedTypes: content.libraryEmbedTypes, + fullscreen: content.libraryFullscreen, + }, + }; + } + + /** + * Load dependencies for the given content of the given type. + * + * @param id Content ID. + * @param type The dependency type. + * @return Content dependencies, indexed by machine name. + */ + loadContentDependencies( + id: number, + type?: string, + siteId?: string, + ): Promise<{[machineName: string]: CoreH5PContentDependencyData}> { + return this.h5pFramework.loadContentDependencies(id, type, siteId); + } + + /** + * Loads a library and its dependencies. + * + * @param machineName The library's machine name. + * @param majorVersion The library's major version. + * @param minorVersion The library's minor version. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data. + */ + loadLibrary(machineName: string, majorVersion: number, minorVersion: number, siteId?: string): Promise { + return this.h5pFramework.loadLibrary(machineName, majorVersion, minorVersion, siteId); + } + + /** + * Check if the current user has permission to update and install new libraries. + * + * @return Whether has permissions. + */ + mayUpdateLibraries(): boolean { + // In the app the installation only affects current user, so the user always has permissions. + return true; + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with content ID. + */ + async saveContent(content: CoreH5PContentBeingSaved, folderName: string, fileUrl: string, siteId?: string): Promise { + content.id = await this.h5pFramework.updateContent(content, folderName, fileUrl, siteId); + + // Some user data for content has to be reset when the content changes. + await this.h5pFramework.resetContentUserData(content.id, siteId); + + return content.id; + } + + /** + * Helper function used to figure out embed and download behaviour. + * + * @param optionName The option name. + * @param permission The permission. + * @param id The package ID. + * @param value Default value. + * @return The value to use. + */ + setDisplayOptionOverrides(optionName: string, permission: number, id: number, value: boolean): boolean { + const behaviour = this.h5pFramework.getOption(optionName, CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW); + + // If never show globally, force hide + if (behaviour == CoreH5PDisplayOptionBehaviour.NEVER_SHOW) { + value = false; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW) { + // If always show or permissions say so, force show + value = true; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_PERMISSIONS) { + value = this.h5pFramework.hasPermission(permission, id); + } + + return value; + } + + /** + * Determine if params contain any match. + * + * @param params Parameters. + * @param pattern Regular expression to identify pattern. + * @return True if params matches pattern. + */ + protected textAddonMatches(params: unknown, pattern: string): boolean { + + if (typeof params == 'string') { + if (params.match(pattern)) { + return true; + } + } else if (typeof params == 'object') { + for (const key in params) { + const value = params[key]; + + if (this.textAddonMatches(value, pattern)) { + return true; + } + } + } + + return false; + } + +} + +/** + * Display options behaviour constants. + */ +export enum CoreH5PDisplayOptionBehaviour { + NEVER_SHOW = 0, + CONTROLLED_BY_AUTHOR_DEFAULT_ON = 1, + CONTROLLED_BY_AUTHOR_DEFAULT_OFF = 2, + ALWAYS_SHOW = 3, + CONTROLLED_BY_PERMISSIONS = 4, +} + +/** + * Permission constants. + */ +export enum CoreH5PPermission { + DOWNLOAD_H5P = 0, + EMBED_H5P = 1, + CREATE_RESTRICTED = 2, + UPDATE_LIBRARIES = 3, + INSTALL_RECOMMENDED = 4, + COPY_H5P = 8, +} + +/** + * Display options as object. + */ +export type CoreH5PDisplayOptions = { + frame?: boolean; + export?: boolean; + embed?: boolean; + copyright?: boolean; + icon?: boolean; + copy?: boolean; +}; + +/** + * Dependency asset. + */ +export type CoreH5PDependencyAsset = { + path: string; // Path to the asset. + version: string; // Dependency version. +}; + +/** + * Dependencies files. + */ +export type CoreH5PDependenciesFiles = { + scripts: CoreH5PDependencyAsset[]; // JS scripts. + styles: CoreH5PDependencyAsset[]; // CSS files. +}; + +/** + * Content data, including main library data. + */ +export type CoreH5PContentData = { + id: number; // The id of the content. + params: string; // The content in json format. + embedType: string; // Embed type to use. + disable: number | null; // H5P Button display options. + folderName: string; // Name of the folder that contains the contents. + title: string; // Main library's title. + slug: string; // Lib title and ID slugified. + filtered: string | null; // Filtered version of json_content. + libraryMajorVersion: number; // Main library's major version. + libraryMinorVersion: number; // Main library's minor version. + metadata: unknown; // Content metadata. + library: CoreH5PContentMainLibraryData; // Main library data. + dependencies?: {[key: string]: CoreH5PContentDepsTreeDependency}; // Dependencies. Calculated in filterParameters. +}; + +/** + * Data about main library of a content. + */ +export type CoreH5PContentMainLibraryData = { + id: number; // The id of the library. + name: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + embedTypes: string; // List of supported embed types. + fullscreen: number; // Display fullscreen button. +}; + +/** + * Content dependency data. + */ +export type CoreH5PContentDependencyData = CoreH5PLibraryBasicDataWithPatch & { + libraryId: number; // The id of the library if it is an existing library. + preloadedJs?: string | string[]; // Comma separated string with js file paths. If already parsed, list of paths. + preloadedCss?: string | string[]; // Comma separated string with css file paths. If already parsed, list of paths. + dropCss?: string; // CSV of machine names. + dependencyType: string; // The dependency type. + path?: string; // Path to the dependency. Calculated in getDependenciesFiles. + version?: string; // Version of the dependency. Calculated in getDependenciesFiles. +}; + +/** + * Data for each content dependency in the dependency tree. + */ +export type CoreH5PContentDepsTreeDependency = { + library: CoreH5PLibraryData | CoreH5PLibraryAddonData; // Library data. + type: string; // Dependency type. + weight?: number; // An integer determining the order of the libraries when they are loaded. +}; + +/** + * Library data. + */ +export type CoreH5PLibraryData = CoreH5PLibraryBasicDataWithPatch & { + libraryId: number; // The id of the library. + title: string; // The human readable name of this library. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedTypes: string; // List of supported embed types. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + dropLibraryCss?: string; // List of libraries that should not have CSS included if this library is used. Comma separated list. + semantics?: CoreH5PSemantics[]; // The semantics definition. If it's a string, it's in json format. + preloadedDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + dynamicDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + editorDependencies: CoreH5PLibraryBasicData[]; // Dependencies. +}; + +/** + * Library basic data. + */ +export type CoreH5PLibraryBasicData = { + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. +}; + +/** + * Library basic data including patch version. + */ +export type CoreH5PLibraryBasicDataWithPatch = CoreH5PLibraryBasicData & { + patchVersion: number; // Patch version. +}; + +/** + * "Addon" data (library). + */ +export type CoreH5PLibraryAddonData = CoreH5PLibraryBasicDataWithPatch & { + libraryId: number; // The id of the library. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + addTo?: CoreH5PLibraryAddTo | null; // Plugin configuration data. +}; diff --git a/src/core/features/h5p/classes/file-storage.ts b/src/core/features/h5p/classes/file-storage.ts new file mode 100644 index 000000000..453d8cd52 --- /dev/null +++ b/src/core/features/h5p/classes/file-storage.ts @@ -0,0 +1,475 @@ +// (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 { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { + CoreH5PCore, + CoreH5PDependencyAsset, + CoreH5PContentDependencyData, + CoreH5PDependenciesFiles, + CoreH5PLibraryBasicData, + CoreH5PContentMainLibraryData, +} from './core'; +import { CONTENTS_LIBRARIES_TABLE_NAME, CONTENT_TABLE_NAME, CoreH5PLibraryCachedAssetsDBRecord } from '../services/database/h5p'; +import { CoreH5PLibraryBeingSaved } from './storage'; + +/** + * Equivalent to Moodle's implementation of H5PFileStorage. + */ +export class CoreH5PFileStorage { + + static readonly CACHED_ASSETS_FOLDER_NAME = 'cachedassets'; + + /** + * Will concatenate all JavaScrips and Stylesheets into two files in order to improve page performance. + * + * @param files A set of all the assets required for content to display. + * @param key Hashed key for cached asset. + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Promise resolved when done. + */ + async cacheAssets(files: CoreH5PDependenciesFiles, key: string, folderName: string, siteId: string): Promise { + + const cachedAssetsPath = this.getCachedAssetsFolderPath(folderName, siteId); + + // Treat each type in the assets. + await Promise.all(Object.keys(files).map(async (type) => { + + const assets: CoreH5PDependencyAsset[] = files[type]; + + if (!assets || !assets.length) { + return; + } + + // Create new file for cached assets. + const fileName = key + '.' + (type == 'scripts' ? 'js' : 'css'); + const path = CoreTextUtils.instance.concatenatePaths(cachedAssetsPath, fileName); + + // Store concatenated content. + const content = await this.concatenateFiles(assets, type); + + await CoreFile.instance.writeFile(path, content); + + // Now update the files data. + files[type] = [ + { + path: CoreTextUtils.instance.concatenatePaths(CoreH5PFileStorage.CACHED_ASSETS_FOLDER_NAME, fileName), + version: '', + }, + ]; + })); + } + + /** + * Adds all files of a type into one file. + * + * @param assets A list of files. + * @param type The type of files in assets. Either 'scripts' or 'styles' + * @return Promise resolved with all of the files content in one string. + */ + protected async concatenateFiles(assets: CoreH5PDependencyAsset[], type: string): Promise { + const basePath = CoreFile.instance.convertFileSrc(CoreFile.instance.getBasePathInstant()); + let content = ''; + + for (const i in assets) { + const asset = assets[i]; + + let fileContent = await CoreFile.instance.readFile(asset.path); + + if (type == 'scripts') { + // No need to treat scripts, just append the content. + content += fileContent + ';\n'; + + continue; + } + + // Rewrite relative URLs used inside stylesheets. + const matches = fileContent.match(/url\(['"]?([^"')]+)['"]?\)/ig); + const assetPath = asset.path.replace(/(^\/|\/$)/g, ''); // Path without start/end slashes. + const treated = {}; + + if (matches && matches.length) { + matches.forEach((match) => { + let url = match.replace(/(url\(['"]?|['"]?\)$)/ig, ''); + + if (treated[url] || url.match(/^(data:|([a-z0-9]+:)?\/)/i)) { + return; // Not relative or already treated, skip. + } + + const pathSplit = assetPath.split('/'); + treated[url] = url; + + /* Find "../" in the URL. If it exists, we have to remove "../" and switch the last folder in the + filepath for the first folder in the url. */ + if (url.match(/^\.\.\//)) { + // Split and remove empty values. + const urlSplit = url.split('/').filter((i) => i); + + // Remove the file name from the asset path. + pathSplit.pop(); + + // Remove the first element from the file URL: ../ . + urlSplit.shift(); + + // Put the url's first folder into the asset path. + pathSplit[pathSplit.length - 1] = urlSplit[0]; + urlSplit.shift(); + + // Create the new URL and replace it in the file contents. + url = pathSplit.join('/') + '/' + urlSplit.join('/'); + + } else { + pathSplit[pathSplit.length - 1] = url; // Put the whole path to the end of the asset path. + url = pathSplit.join('/'); + } + + fileContent = fileContent.replace( + new RegExp(CoreTextUtils.instance.escapeForRegex(match), 'g'), + 'url("' + CoreTextUtils.instance.concatenatePaths(basePath, url) + '")', + ); + }); + } + + content += fileContent + '\n'; + } + + return content; + } + + /** + * Delete cached assets from file system. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteCachedAssets(removedEntries: CoreH5PLibraryCachedAssetsDBRecord[], siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const promises: Promise[] = []; + + removedEntries.forEach((entry) => { + const cachedAssetsFolder = this.getCachedAssetsFolderPath(entry.foldername, site.getId()); + + ['js', 'css'].forEach((type) => { + const path = CoreTextUtils.instance.concatenatePaths(cachedAssetsFolder, entry.hash + '.' + type); + + promises.push(CoreFile.instance.removeFile(path)); + }); + }); + + // Ignore errors, maybe there's no cached asset of some type. + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(promises)); + } + + /** + * Deletes a content folder from the file system. + * + * @param folderName Folder name of the content. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentFolder(folderName: string, siteId: string): Promise { + await CoreFile.instance.removeDir(this.getContentFolderPath(folderName, siteId)); + } + + /** + * Delete content indexes from filesystem. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentIndex(folderName: string, siteId: string): Promise { + await CoreFile.instance.removeFile(this.getContentIndexPath(folderName, siteId)); + } + + /** + * Delete content indexes from filesystem. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentIndexesForLibrary(libraryId: number, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + + // Get the folder names of all the packages that use this library. + const query = 'SELECT DISTINCT hc.foldername ' + + 'FROM ' + CONTENTS_LIBRARIES_TABLE_NAME + ' hcl ' + + 'JOIN ' + CONTENT_TABLE_NAME + ' hc ON hcl.h5pid = hc.id ' + + 'WHERE hcl.libraryid = ?'; + const queryArgs = [libraryId]; + + const result = await db.execute(query, queryArgs); + + await Array.from(result.rows).map(async (entry: {foldername: string}) => { + try { + // Delete the index.html. + await this.deleteContentIndex(entry.foldername, site.getId()); + } catch (error) { + // Ignore errors. + } + }); + } + + /** + * Deletes a library from the file system. + * + * @param libraryData The library data. + * @param siteId Site ID. + * @param folderName Folder name. If not provided, it will be calculated. + * @return Promise resolved when done. + */ + async deleteLibraryFolder( + libraryData: CoreH5PLibraryBasicData | CoreH5PContentMainLibraryData, + siteId: string, + folderName?: string, + ): Promise { + await CoreFile.instance.removeDir(this.getLibraryFolderPath(libraryData, siteId, folderName)); + } + + /** + * Will check if there are cache assets available for content. + * + * @param key Hashed key for cached asset + * @return Promise resolved with the files. + */ + async getCachedAssets(key: string): Promise<{scripts?: CoreH5PDependencyAsset[]; styles?: CoreH5PDependencyAsset[]} | null> { + + // Get JS and CSS cached assets if they exist. + const results = await Promise.all([ + this.getCachedAsset(key, '.js'), + this.getCachedAsset(key, '.css'), + ]); + + const files = { + scripts: results[0], + styles: results[1], + }; + + return files.scripts || files.styles ? files : null; + } + + /** + * Check if a cached asset file exists and, if so, return its data. + * + * @param key Key of the cached asset. + * @param extension Extension of the file to get. + * @return Promise resolved with the list of assets (only one), undefined if not found. + */ + protected async getCachedAsset(key: string, extension: string): Promise { + + try { + const path = CoreTextUtils.instance.concatenatePaths(CoreH5PFileStorage.CACHED_ASSETS_FOLDER_NAME, key + extension); + + const size = await CoreFile.instance.getFileSize(path); + + if (size > 0) { + return [ + { + path: path, + version: '', + }, + ]; + } + } catch (error) { + // Not found, nothing to do. + } + } + + /** + * Get relative path to a content cached assets. + * + * @param folderName Name of the folder of the content the assets belong to. + * @param siteId Site ID. + * @return Path. + */ + getCachedAssetsFolderPath(folderName: string, siteId: string): string { + return CoreTextUtils.instance.concatenatePaths( + this.getContentFolderPath(folderName, siteId), + CoreH5PFileStorage.CACHED_ASSETS_FOLDER_NAME, + ); + } + + /** + * Get a content folder name given the package URL. + * + * @param fileUrl Package URL. + * @param siteId Site ID. + * @return Promise resolved with the folder name. + */ + async getContentFolderNameByUrl(fileUrl: string, siteId: string): Promise { + const path = await CoreFilepool.instance.getFilePathByUrl(siteId, fileUrl); + + const fileAndDir = CoreFile.instance.getFileAndDirectoryFromPath(path); + + return CoreMimetypeUtils.instance.removeExtension(fileAndDir.name); + } + + /** + * Get a package content path. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentFolderPath(folderName: string, siteId: string): string { + return CoreTextUtils.instance.concatenatePaths( + this.getExternalH5PFolderPath(siteId), + 'packages/' + folderName + '/content', + ); + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + async getContentIndexFileUrl(fileUrl: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const folderName = await this.getContentFolderNameByUrl(fileUrl, siteId); + + const file = await CoreFile.instance.getFile(this.getContentIndexPath(folderName, siteId)); + + return file.toURL(); + } + + /** + * Get the path to a content index. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentIndexPath(folderName: string, siteId: string): string { + return CoreTextUtils.instance.concatenatePaths(this.getContentFolderPath(folderName, siteId), 'index.html'); + } + + /** + * Get the path to the folder that contains the H5P core libraries. + * + * @return Folder path. + */ + getCoreH5PPath(): string { + return CoreTextUtils.instance.concatenatePaths(CoreFile.instance.getWWWPath(), '/h5p/'); + } + + /** + * Get the path to the dependency. + * + * @param dependency Dependency library. + * @return The path to the dependency library + */ + getDependencyPath(dependency: CoreH5PContentDependencyData): string { + return 'libraries/' + dependency.machineName + '-' + dependency.majorVersion + '.' + dependency.minorVersion; + } + + /** + * Get path to the folder containing H5P files extracted from packages. + * + * @param siteId The site ID. + * @return Folder path. + */ + getExternalH5PFolderPath(siteId: string): string { + return CoreTextUtils.instance.concatenatePaths(CoreFile.instance.getSiteFolder(siteId), 'h5p'); + } + + /** + * Get libraries folder path. + * + * @param siteId The site ID. + * @return Folder path. + */ + getLibrariesFolderPath(siteId: string): string { + return CoreTextUtils.instance.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'libraries'); + } + + /** + * Get a library's folder path. + * + * @param libraryData The library data. + * @param siteId The site ID. + * @param folderName Folder name. If not provided, it will be calculated. + * @return Folder path. + */ + getLibraryFolderPath( + libraryData: CoreH5PLibraryBasicData | CoreH5PContentMainLibraryData, + siteId: string, + folderName?: string, + ): string { + if (!folderName) { + folderName = CoreH5PCore.libraryToString(libraryData, true); + } + + return CoreTextUtils.instance.concatenatePaths(this.getLibrariesFolderPath(siteId), folderName); + } + + /** + * Save the content in filesystem. + * + * @param contentPath Path to the current content folder (tmp). + * @param folderName Name to put to the content folder. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + async saveContent(contentPath: string, folderName: string, siteId: string): Promise { + const folderPath = this.getContentFolderPath(folderName, siteId); + + // Delete existing content for this package. + await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + + // Copy the new one. + await CoreFile.instance.moveDir(contentPath, folderPath); + } + + /** + * Save a library in filesystem. + * + * @param libraryData Library data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibrary(libraryData: CoreH5PLibraryBeingSaved, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const folderPath = this.getLibraryFolderPath(libraryData, siteId); + + // Delete existing library version. + try { + await CoreFile.instance.removeDir(folderPath); + } catch (error) { + // Ignore errors, maybe it doesn't exist. + } + + if (libraryData.uploadDirectory) { + // Copy the new one. + await CoreFile.instance.moveDir(libraryData.uploadDirectory, folderPath, true); + } + } + +} diff --git a/src/core/features/h5p/classes/framework.ts b/src/core/features/h5p/classes/framework.ts new file mode 100644 index 000000000..47cdea7ae --- /dev/null +++ b/src/core/features/h5p/classes/framework.ts @@ -0,0 +1,917 @@ +// (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 { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { + CoreH5PCore, + CoreH5PDisplayOptionBehaviour, + CoreH5PContentDependencyData, + CoreH5PLibraryData, + CoreH5PLibraryAddonData, + CoreH5PContentDepsTreeDependency, + CoreH5PLibraryBasicData, + CoreH5PLibraryBasicDataWithPatch, +} from './core'; +import { + CONTENT_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, + CoreH5PLibraryCachedAssetsDBRecord, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + CoreH5PContentDBRecord, + CoreH5PLibraryDBRecord, + CoreH5PLibraryDependencyDBRecord, + CoreH5PContentsLibraryDBRecord, +} from '../services/database/h5p'; +import { CoreError } from '@classes/errors/error'; +import { CoreH5PSemantics } from './content-validator'; +import { CoreH5PContentBeingSaved, CoreH5PLibraryBeingSaved } from './storage'; +import { CoreH5PLibraryAddTo } from './validator'; + +/** + * Equivalent to Moodle's implementation of H5PFrameworkInterface. + */ +export class CoreH5PFramework { + + /** + * Will clear filtered params for all the content that uses the specified libraries. + * This means that the content dependencies will have to be rebuilt and the parameters re-filtered. + * + * @param libraryIds Array of library ids. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async clearFilteredParameters(libraryIds: number[], siteId?: string): Promise { + if (!libraryIds || !libraryIds.length) { + return; + } + + const db = await CoreSites.instance.getSiteDb(siteId); + + const whereAndParams = db.getInOrEqual(libraryIds); + whereAndParams[0] = 'mainlibraryid ' + whereAndParams[0]; + + await db.updateRecordsWhere(CONTENT_TABLE_NAME, { filtered: null }, whereAndParams[0], whereAndParams[1]); + } + + /** + * Delete cached assets from DB. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the removed entries. + */ + async deleteCachedAssets(libraryId: number, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + // Get all the hashes that use this library. + const entries = await db.getRecords( + LIBRARIES_CACHEDASSETS_TABLE_NAME, + { libraryid: libraryId }, + ); + + const hashes = entries.map((entry) => entry.hash); + + if (hashes.length) { + // Delete the entries from DB. + await db.deleteRecordsList(LIBRARIES_CACHEDASSETS_TABLE_NAME, 'hash', hashes); + } + + return entries; + } + + /** + * Delete content data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentData(id: number, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all([ + // Delete the content data. + db.deleteRecords(CONTENT_TABLE_NAME, { id }), + + // Remove content library dependencies. + this.deleteLibraryUsage(id, siteId), + ]); + } + + /** + * Delete library data from DB. + * + * @param id Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteLibrary(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(LIBRARIES_TABLE_NAME, { id }); + } + + /** + * Delete all dependencies belonging to given library. + * + * @param libraryId Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteLibraryDependencies(libraryId: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(LIBRARY_DEPENDENCIES_TABLE_NAME, { libraryid: libraryId }); + } + + /** + * Delete what libraries a content item is using. + * + * @param id Package ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteLibraryUsage(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(CONTENTS_LIBRARIES_TABLE_NAME, { h5pid: id }); + } + + /** + * Get all conent data from DB. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of content data. + */ + async getAllContentData(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getAllRecords(CONTENT_TABLE_NAME); + } + + /** + * Get conent data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async getContentData(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecord(CONTENT_TABLE_NAME, { id }); + } + + /** + * Get conent data from DB. + * + * @param fileUrl H5P file URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async getContentDataByUrl(fileUrl: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + + // Try to use the folder name, it should be more reliable than the URL. + const folderName = await CoreH5P.instance.h5pCore.h5pFS.getContentFolderNameByUrl(fileUrl, site.getId()); + + try { + return await db.getRecord(CONTENT_TABLE_NAME, { foldername: folderName }); + } catch (error) { + // Cannot get folder name, the h5p file was probably deleted. Just use the URL. + return db.getRecord(CONTENT_TABLE_NAME, { fileurl: fileUrl }); + } + } + + /** + * Get the latest library version. + * + * @param machineName The library's machine name. + * @return Promise resolved with the latest library version data. + */ + async getLatestLibraryVersion(machineName: string, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + try { + const records = await db.getRecords( + LIBRARIES_TABLE_NAME, + { machinename: machineName }, + 'majorversion DESC, minorversion DESC, patchversion DESC', + '*', + 0, + 1, + ); + + if (records && records[0]) { + return this.parseLibDBData(records[0]); + } + } catch (error) { + // Library not found. + } + + throw new CoreError(`Missing required library: ${machineName}`); + } + + /** + * Get a library data stored in DB. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected async getLibrary( + machineName: string, + majorVersion?: string | number, + minorVersion?: string | number, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const libraries = await db.getRecords(LIBRARIES_TABLE_NAME, { + machinename: machineName, + majorversion: majorVersion, + minorversion: minorVersion, + }); + + if (!libraries.length) { + throw new CoreError('Libary not found.'); + } + + return this.parseLibDBData(libraries[0]); + } + + /** + * Get a library data stored in DB. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + getLibraryByData(libraryData: CoreH5PLibraryBasicData, siteId?: string): Promise { + return this.getLibrary(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get a library data stored in DB by ID. + * + * @param id Library ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + async getLibraryById(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const library = await db.getRecord(LIBRARIES_TABLE_NAME, { id }); + + return this.parseLibDBData(library); + } + + /** + * Get a library ID. If not found, return null. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + async getLibraryId( + machineName: string, + majorVersion?: string | number, + minorVersion?: string | number, + siteId?: string, + ): Promise { + try { + const library = await this.getLibrary(machineName, majorVersion, minorVersion, siteId); + + return library.id || undefined; + } catch (error) { + return undefined; + } + } + + /** + * Get a library ID. If not found, return null. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + getLibraryIdByData(libraryData: CoreH5PLibraryBasicData, siteId?: string): Promise { + return this.getLibraryId(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get the default behaviour for the display option defined. + * + * @param name Identifier for the setting. + * @param defaultValue Optional default value if settings is not set. + * @return Return the value for this display option. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getOption(name: string, defaultValue: unknown): unknown { + // For now, all them are disabled by default, so only will be rendered when defined in the display options. + return CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_AUTHOR_DEFAULT_OFF; + } + + /** + * Check whether the user has permission to execute an action. + * + * @param permission Permission to check. + * @param id H5P package id. + * @return Whether the user has permission to execute an action. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hasPermission(permission: number, id: number): boolean { + // H5P capabilities have not been introduced. + return true; + } + + /** + * Determines if content slug is used. + * + * @param slug The content slug. + * @return Whether the content slug is used + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isContentSlugAvailable(slug: string): boolean { + // By default the slug should be available as it's currently generated as a unique value for each h5p content. + return true; + } + + /** + * Check whether a library is a patched version of the one installed. + * + * @param library Library to check. + * @param dbData Installed library. If not supplied it will be calculated. + * @return Promise resolved with boolean: whether it's a patched library. + */ + async isPatchedLibrary(library: CoreH5PLibraryBasicDataWithPatch, dbData?: CoreH5PLibraryParsedDBRecord): Promise { + if (!dbData) { + dbData = await this.getLibraryByData(library); + } + + return library.patchVersion > dbData.patchversion; + } + + /** + * Convert list of library parameter values to csv. + * + * @param libraryData Library data as found in library.json files. + * @param key Key that should be found in libraryData. + * @param searchParam The library parameter (Default: 'path'). + * @return Library parameter values separated by ', ' + */ + libraryParameterValuesToCsv(libraryData: CoreH5PLibraryBeingSaved, key: string, searchParam: string = 'path'): string { + if (typeof libraryData[key] != 'undefined') { + const parameterValues: string[] = []; + + libraryData[key].forEach((file) => { + for (const index in file) { + if (index === searchParam) { + parameterValues.push(file[index]); + } + } + }); + + return parameterValues.join(','); + } + + return ''; + } + + /** + * Load addon libraries. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the addon libraries. + */ + async loadAddons(siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const query = 'SELECT l1.id AS libraryId, l1.machinename AS machineName, ' + + 'l1.majorversion AS majorVersion, l1.minorversion AS minorVersion, ' + + 'l1.patchversion AS patchVersion, l1.addto AS addTo, ' + + 'l1.preloadedjs AS preloadedJs, l1.preloadedcss AS preloadedCss ' + + 'FROM ' + LIBRARIES_TABLE_NAME + ' l1 ' + + 'JOIN ' + LIBRARIES_TABLE_NAME + ' l2 ON l1.machinename = l2.machinename AND (' + + 'l1.majorversion < l2.majorversion OR (l1.majorversion = l2.majorversion AND ' + + 'l1.minorversion < l2.minorversion)) ' + + 'WHERE l1.addto IS NOT NULL AND l2.machinename IS NULL'; + + const result = await db.execute(query); + + const addons: CoreH5PLibraryAddonData[] = []; + + for (let i = 0; i < result.rows.length; i++) { + addons.push(this.parseLibAddonData(result.rows.item(i))); + } + + return addons; + } + + /** + * Load content data from DB. + * + * @param id Content ID. + * @param fileUrl H5P file URL. Required if id is not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async loadContent(id?: number, fileUrl?: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + let contentData: CoreH5PContentDBRecord; + + if (id) { + contentData = await this.getContentData(id, siteId); + } else if (fileUrl) { + contentData = await this.getContentDataByUrl(fileUrl, siteId); + } else { + throw new CoreError('No id or fileUrl supplied to loadContent.'); + } + + // Load the main library data. + const libData = await this.getLibraryById(contentData.mainlibraryid, siteId); + + // Map the values to the names used by the H5P core (it's the same Moodle web does). + const content = { + id: contentData.id, + params: contentData.jsoncontent, + embedType: 'iframe', // Always use iframe. + disable: null, + folderName: contentData.foldername, + title: libData.title, + slug: CoreH5PCore.slugify(libData.title) + '-' + contentData.id, + filtered: contentData.filtered, + libraryId: libData.id, + libraryName: libData.machinename, + libraryMajorVersion: libData.majorversion, + libraryMinorVersion: libData.minorversion, + libraryEmbedTypes: libData.embedtypes, + libraryFullscreen: libData.fullscreen, + metadata: null, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params = CoreTextUtils.instance.parseJSON(contentData.jsoncontent); + if (!params.metadata) { + params.metadata = {}; + } + content.metadata = params.metadata; + content.params = JSON.stringify(typeof params.params != 'undefined' && params.params != null ? params.params : params); + + return content; + } + + /** + * Load dependencies for the given content of the given type. + * + * @param id Content ID. + * @param type The dependency type. + * @return Content dependencies, indexed by machine name. + */ + async loadContentDependencies( + id: number, + type?: string, + siteId?: string, + ): Promise<{[machineName: string]: CoreH5PContentDependencyData}> { + + const db = await CoreSites.instance.getSiteDb(siteId); + + let query = 'SELECT hl.id AS libraryId, hl.machinename AS machineName, ' + + 'hl.majorversion AS majorVersion, hl.minorversion AS minorVersion, ' + + 'hl.patchversion AS patchVersion, hl.preloadedcss AS preloadedCss, ' + + 'hl.preloadedjs AS preloadedJs, hcl.dropcss AS dropCss, ' + + 'hcl.dependencytype as dependencyType ' + + 'FROM ' + CONTENTS_LIBRARIES_TABLE_NAME + ' hcl ' + + 'JOIN ' + LIBRARIES_TABLE_NAME + ' hl ON hcl.libraryid = hl.id ' + + 'WHERE hcl.h5pid = ?'; + + const queryArgs: (string | number)[] = []; + queryArgs.push(id); + + if (type) { + query += ' AND hcl.dependencytype = ?'; + queryArgs.push(type); + } + + query += ' ORDER BY hcl.weight'; + + const result = await db.execute(query, queryArgs); + + const dependencies: {[machineName: string]: CoreH5PContentDependencyData} = {}; + + for (let i = 0; i < result.rows.length; i++) { + const dependency = result.rows.item(i); + + dependencies[dependency.machineName] = dependency; + } + + return dependencies; + } + + /** + * Loads a library and its dependencies. + * + * @param machineName The library's machine name. + * @param majorVersion The library's major version. + * @param minorVersion The library's minor version. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data. + */ + async loadLibrary( + machineName: string, + majorVersion: number, + minorVersion: number, + siteId?: string, + ): Promise { + + // First get the library data from DB. + const library = await this.getLibrary(machineName, majorVersion, minorVersion, siteId); + + const libraryData: CoreH5PLibraryData = { + libraryId: library.id, + title: library.title, + machineName: library.machinename, + majorVersion: library.majorversion, + minorVersion: library.minorversion, + patchVersion: library.patchversion, + runnable: library.runnable, + fullscreen: library.fullscreen, + embedTypes: library.embedtypes, + preloadedJs: library.preloadedjs || undefined, + preloadedCss: library.preloadedcss || undefined, + dropLibraryCss: library.droplibrarycss || undefined, + semantics: library.semantics || undefined, + preloadedDependencies: [], + dynamicDependencies: [], + editorDependencies: [], + }; + + // Now get the dependencies. + const sql = 'SELECT hl.id, hl.machinename, hl.majorversion, hl.minorversion, hll.dependencytype ' + + 'FROM ' + LIBRARY_DEPENDENCIES_TABLE_NAME + ' hll ' + + 'JOIN ' + LIBRARIES_TABLE_NAME + ' hl ON hll.requiredlibraryid = hl.id ' + + 'WHERE hll.libraryid = ? ' + + 'ORDER BY hl.id ASC'; + + const sqlParams = [ + library.id, + ]; + + const db = await CoreSites.instance.getSiteDb(siteId); + + const result = await db.execute(sql, sqlParams); + + for (let i = 0; i < result.rows.length; i++) { + const dependency: LibraryDependency = result.rows.item(i); + const key = dependency.dependencytype + 'Dependencies'; + + libraryData[key].push({ + machineName: dependency.machinename, + majorVersion: dependency.majorversion, + minorVersion: dependency.minorversion, + }); + } + + return libraryData; + } + + /** + * Parse library addon data. + * + * @param library Library addon data. + * @return Parsed library. + */ + parseLibAddonData(library: LibraryAddonDBData): CoreH5PLibraryAddonData { + const parsedLib = library; + parsedLib.addTo = CoreTextUtils.instance.parseJSON(library.addTo, null); + + return parsedLib; + } + + /** + * Parse library DB data. + * + * @param library Library DB data. + * @return Parsed library. + */ + protected parseLibDBData(library: CoreH5PLibraryDBRecord): CoreH5PLibraryParsedDBRecord { + return Object.assign(library, { + semantics: library.semantics ? CoreTextUtils.instance.parseJSON(library.semantics, null) : null, + addto: library.addto ? CoreTextUtils.instance.parseJSON(library.addto, null) : null, + }); + } + + /** + * Resets marked user data for the given content. + * + * @param contentId Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async resetContentUserData(conentId: number, siteId?: string): Promise { + // Currently, we do not store user data for a content. + } + + /** + * Stores hash keys for cached assets, aggregated JavaScripts and stylesheets, and connects it to libraries so that we + * know which cache file to delete when a library is updated. + * + * @param key Hash key for the given libraries. + * @param libraries List of dependencies used to create the key. + * @param folderName The name of the folder that contains the H5P. + * @param siteId The site ID. + * @return Promise resolved when done. + */ + async saveCachedAssets( + hash: string, + dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + folderName: string, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all(Object.keys(dependencies).map(async (key) => { + const data: Partial = { + hash: key, + libraryid: dependencies[key].libraryId, + foldername: folderName, + }; + + await db.insertRecord(LIBRARIES_CACHEDASSETS_TABLE_NAME, data); + })); + } + + /** + * Save library data in DB. + * + * @param libraryData Library data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibraryData(libraryData: CoreH5PLibraryBeingSaved, siteId?: string): Promise { + // Some special properties needs some checking and converting before they can be saved. + const preloadedJS = this.libraryParameterValuesToCsv(libraryData, 'preloadedJs', 'path'); + const preloadedCSS = this.libraryParameterValuesToCsv(libraryData, 'preloadedCss', 'path'); + const dropLibraryCSS = this.libraryParameterValuesToCsv(libraryData, 'dropLibraryCss', 'machineName'); + + if (typeof libraryData.semantics == 'undefined') { + libraryData.semantics = []; + } + if (typeof libraryData.fullscreen == 'undefined') { + libraryData.fullscreen = 0; + } + + let embedTypes = ''; + if (typeof libraryData.embedTypes != 'undefined') { + embedTypes = libraryData.embedTypes.join(', '); + } + + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + const data: Partial = { + title: libraryData.title, + machinename: libraryData.machineName, + majorversion: libraryData.majorVersion, + minorversion: libraryData.minorVersion, + patchversion: libraryData.patchVersion, + runnable: libraryData.runnable, + fullscreen: libraryData.fullscreen, + embedtypes: embedTypes, + preloadedjs: preloadedJS, + preloadedcss: preloadedCSS, + droplibrarycss: dropLibraryCSS, + semantics: typeof libraryData.semantics != 'undefined' ? JSON.stringify(libraryData.semantics) : null, + addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, + }; + + if (libraryData.libraryId) { + data.id = libraryData.libraryId; + } + + await db.insertRecord(LIBRARIES_TABLE_NAME, data); + + if (!data.id) { + // New library. Get its ID. + const entry = await db.getRecord(LIBRARIES_TABLE_NAME, data); + + libraryData.libraryId = entry.id; + } else { + // Updated libary. Remove old dependencies. + await this.deleteLibraryDependencies(data.id, site.getId()); + } + } + + /** + * Save what libraries a library is depending on. + * + * @param libraryId Library Id for the library we're saving dependencies for. + * @param dependencies List of dependencies as associative arrays containing machineName, majorVersion, minorVersion. + * @param dependencytype The type of dependency. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibraryDependencies( + libraryId: number, + dependencies: CoreH5PLibraryBasicData[], + dependencyType: string, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all(dependencies.map(async (dependency) => { + // Get the ID of the library. + const dependencyId = await this.getLibraryIdByData(dependency, siteId); + + // Create the relation. + const entry: Partial = { + libraryid: libraryId, + requiredlibraryid: dependencyId, + dependencytype: dependencyType, + }; + + await db.insertRecord(LIBRARY_DEPENDENCIES_TABLE_NAME, entry); + })); + } + + /** + * Saves what libraries the content uses. + * + * @param id Id identifying the package. + * @param librariesInUse List of libraries the content uses. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibraryUsage( + id: number, + librariesInUse: {[key: string]: CoreH5PContentDepsTreeDependency}, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + // Calculate the CSS to drop. + const dropLibraryCssList: Record = {}; + + for (const key in librariesInUse) { + const dependency = librariesInUse[key]; + + if ('dropLibraryCss' in dependency.library && dependency.library.dropLibraryCss) { + const split = dependency.library.dropLibraryCss.split(', '); + + split.forEach((css) => { + dropLibraryCssList[css] = css; + }); + } + } + + // Now save the uusage. + await Promise.all(Object.keys(librariesInUse).map((key) => { + const dependency = librariesInUse[key]; + const data: Partial = { + h5pid: id, + libraryid: dependency.library.libraryId, + dependencytype: dependency.type, + dropcss: dropLibraryCssList[dependency.library.machineName] ? 1 : 0, + weight: dependency.weight, + }; + + return db.insertRecord(CONTENTS_LIBRARIES_TABLE_NAME, data); + })); + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with content ID. + */ + async updateContent(content: CoreH5PContentBeingSaved, folderName: string, fileUrl: string, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + // If the libraryid declared in the package is empty, get the latest version. + if (content.library && typeof content.library.libraryId == 'undefined') { + const mainLibrary = await this.getLatestLibraryVersion(content.library.machineName, siteId); + + content.library.libraryId = mainLibrary.id; + } + + const data: Partial = { + id: undefined, + jsoncontent: content.params, + mainlibraryid: content.library?.libraryId, + timemodified: Date.now(), + filtered: null, + foldername: folderName, + fileurl: fileUrl, + timecreated: undefined, + }; + + if (typeof content.id != 'undefined') { + data.id = content.id; + } else { + data.timecreated = data.timemodified; + } + + await db.insertRecord(CONTENT_TABLE_NAME, data); + + if (!data.id) { + // New content. Get its ID. + const entry = await db.getRecord(CONTENT_TABLE_NAME, data); + + content.id = entry.id; + } + + return content.id!; + } + + /** + * This will update selected fields on the given content. + * + * @param id Content identifier. + * @param fields Object with the fields to update. + * @param siteId Site ID. If not defined, current site. + */ + async updateContentFields(id: number, fields: Partial, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const data = Object.assign({}, fields); + + await db.updateRecords(CONTENT_TABLE_NAME, data, { id }); + } + +} + +/** + * Content data returned by loadContent. + */ +export type CoreH5PFrameworkContentData = { + id: number; // The id of the content. + params: string; // The content in json format. + embedType: string; // Embed type to use. + disable: number | null; // H5P Button display options. + folderName: string; // Name of the folder that contains the contents. + title: string; // Main library's title. + slug: string; // Lib title and ID slugified. + filtered: string | null; // Filtered version of json_content. + libraryId: number; // Main library's ID. + libraryName: string; // Main library's machine name. + libraryMajorVersion: number; // Main library's major version. + libraryMinorVersion: number; // Main library's minor version. + libraryEmbedTypes: string; // Main library's list of supported embed types. + libraryFullscreen: number; // Main library's display fullscreen button. + metadata: unknown; // Content metadata. +}; + +export type CoreH5PLibraryParsedDBRecord = Omit & { + semantics: CoreH5PSemantics[] | null; + addto: CoreH5PLibraryAddTo | null; +}; + +type LibraryDependency = { + id: number; + machinename: string; + majorversion: number; + minorversion: number; + dependencytype: string; +}; + +type LibraryAddonDBData = Omit & { + addTo: string; +}; + diff --git a/src/core/features/h5p/classes/helper.ts b/src/core/features/h5p/classes/helper.ts new file mode 100644 index 000000000..4a0822ce0 --- /dev/null +++ b/src/core/features/h5p/classes/helper.ts @@ -0,0 +1,255 @@ +// (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 { FileEntry } from '@ionic-native/file'; + +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreUser } from '@features/user/services/user'; +import { CoreH5P } from '../services/h5p'; +import { CoreH5PCore, CoreH5PDisplayOptions } from './core'; +import { Translate } from '@singletons'; +import { CoreError } from '@classes/errors/error'; + +/** + * Equivalent to Moodle's H5P helper class. + */ +export class CoreH5PHelper { + + /** + * Convert the number representation of display options into an object. + * + * @param displayOptions Number representing display options. + * @return Object with display options. + */ + static decodeDisplayOptions(displayOptions: number): CoreH5PDisplayOptions { + const displayOptionsObject = CoreH5P.instance.h5pCore.getDisplayOptionsAsObject(displayOptions); + + const config: CoreH5PDisplayOptions = { + export: false, // Don't allow downloading in the app. + embed: false, // Don't display the embed button in the app. + copyright: CoreUtils.instance.notNullOrUndefined(displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT]) ? + displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] : false, + icon: CoreUtils.instance.notNullOrUndefined(displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_ABOUT]) ? + displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_ABOUT] : false, + }; + + config.frame = config.copyright || config.export || config.embed; + + return config; + } + + /** + * Get the core H5P assets, including all core H5P JavaScript and CSS. + * + * @return Array core H5P assets. + */ + static async getCoreAssets( + siteId?: string, + ): Promise<{settings: CoreH5PCoreSettings; cssRequires: string[]; jsRequires: string[]}> { + + // Get core settings. + const settings = await CoreH5PHelper.getCoreSettings(siteId); + + settings.core = { + styles: [], + scripts: [], + }; + settings.loadedJs = []; + settings.loadedCss = []; + + const libUrl = CoreH5P.instance.h5pCore.h5pFS.getCoreH5PPath(); + const cssRequires: string[] = []; + const jsRequires: string[] = []; + + // Add core stylesheets. + CoreH5PCore.STYLES.forEach((style) => { + settings.core!.styles.push(libUrl + style); + cssRequires.push(libUrl + style); + }); + + // Add core JavaScript. + CoreH5PCore.getScripts().forEach((script) => { + settings.core!.scripts.push(script); + jsRequires.push(script); + }); + + return { settings, cssRequires, jsRequires }; + } + + /** + * Get the settings needed by the H5P library. + * + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the settings. + */ + static async getCoreSettings(siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const userId = site.getUserId(); + const user = await CoreUtils.instance.ignoreErrors(CoreUser.instance.getProfile(userId, undefined, false, siteId)); + + if (!user || !user.email) { + throw new CoreError(Translate.instance.instant('core.h5p.errorgetemail')); + } + + const basePath = CoreFile.instance.getBasePathInstant(); + const ajaxPaths = { + xAPIResult: '', + contentUserData: '', + }; + + return { + baseUrl: CoreFile.instance.getWWWPath(), + url: CoreFile.instance.convertFileSrc( + CoreTextUtils.instance.concatenatePaths( + basePath, + CoreH5P.instance.h5pCore.h5pFS.getExternalH5PFolderPath(site.getId()), + ), + ), + urlLibraries: CoreFile.instance.convertFileSrc( + CoreTextUtils.instance.concatenatePaths( + basePath, + CoreH5P.instance.h5pCore.h5pFS.getLibrariesFolderPath(site.getId()), + ), + ), + postUserStatistics: false, + ajax: ajaxPaths, + saveFreq: false, + siteUrl: site.getURL(), + l10n: { + H5P: CoreH5P.instance.h5pCore.getLocalization(), // eslint-disable-line @typescript-eslint/naming-convention + }, + user: { name: site.getInfo()!.fullname, mail: user.email }, + hubIsEnabled: false, + reportingIsEnabled: false, + crossorigin: null, + libraryConfig: null, + pluginCacheBuster: '', + libraryUrl: CoreTextUtils.instance.concatenatePaths(CoreH5P.instance.h5pCore.h5pFS.getCoreH5PPath(), 'js'), + }; + } + + /** + * Extract and store an H5P file. + * This function won't validate most things because it should've been done by the server already. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + static async saveH5P(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: CoreH5PSaveOnProgress): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Notify that the unzip is starting. + onProgress && onProgress({ message: 'core.unzipping' }); + + const queueId = siteId + ':saveH5P:' + fileUrl; + + await CoreH5P.instance.queueRunner.run(queueId, () => CoreH5PHelper.performSave(fileUrl, file, siteId, onProgress)); + } + + /** + * Extract and store an H5P file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + protected static async performSave( + fileUrl: string, + file: FileEntry, + siteId?: string, + onProgress?: CoreH5PSaveOnProgress, + ): Promise { + + const folderName = CoreMimetypeUtils.instance.removeExtension(file.name); + const destFolder = CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); + + // Unzip the file. + await CoreFile.instance.unzipFile(file.toURL(), destFolder, onProgress); + + try { + // Notify that the unzip is starting. + onProgress && onProgress({ message: 'core.storingfiles' }); + + // Read the contents of the unzipped dir, process them and store them. + const contents = await CoreFile.instance.getDirectoryContents(destFolder); + + const filesData = await CoreH5P.instance.h5pValidator.processH5PFiles(destFolder, contents); + + const content = await CoreH5P.instance.h5pStorage.savePackage(filesData, folderName, fileUrl, false, siteId); + + // Create the content player. + const contentData = await CoreH5P.instance.h5pCore.loadContent(content.id, undefined, siteId); + + const embedType = CoreH5PCore.determineEmbedType(contentData.embedType, contentData.library.embedTypes); + + await CoreH5P.instance.h5pPlayer.createContentIndex(content.id!, fileUrl, contentData, embedType, siteId); + } finally { + // Remove tmp folder. + try { + await CoreFile.instance.removeDir(destFolder); + } catch (error) { + // Ignore errors, it will be deleted eventually. + } + } + } + +} + +/** + * Core settings for H5P. + */ +export type CoreH5PCoreSettings = { + baseUrl: string; + url: string; + urlLibraries: string; + postUserStatistics: boolean; + ajax: { + xAPIResult: string; + contentUserData: string; + }; + saveFreq: boolean; + siteUrl: string; + l10n: { + H5P: {[name: string]: string}; // eslint-disable-line @typescript-eslint/naming-convention + }; + user: { + name: string; + mail: string; + }; + hubIsEnabled: boolean; + reportingIsEnabled: boolean; + crossorigin: null; + libraryConfig: null; + pluginCacheBuster: string; + libraryUrl: string; + core?: { + styles: string[]; + scripts: string[]; + }; + loadedJs?: string[]; + loadedCss?: string[]; +}; + +export type CoreH5PSaveOnProgress = (event?: ProgressEvent | { message: string }) => void; diff --git a/src/core/features/h5p/classes/metadata.ts b/src/core/features/h5p/classes/metadata.ts new file mode 100644 index 000000000..69c44054c --- /dev/null +++ b/src/core/features/h5p/classes/metadata.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 { CoreH5PLibraryMetadataSettings } from './validator'; + +/** + * Equivalent to H5P's H5PMetadata class. + */ +export class CoreH5PMetadata { + + /** + * The metadataSettings field in libraryJson uses 1 for true and 0 for false. + * Here we are converting these to booleans, and also doing JSON encoding. + * + * @param metadataSettings Settings. + * @return Stringified settings. + */ + static boolifyAndEncodeSettings(metadataSettings: CoreH5PLibraryMetadataSettings): string { + // Convert metadataSettings values to boolean. + if (typeof metadataSettings.disable != 'undefined') { + metadataSettings.disable = metadataSettings.disable === 1; + } + if (typeof metadataSettings.disableExtraTitleField != 'undefined') { + metadataSettings.disableExtraTitleField = metadataSettings.disableExtraTitleField === 1; + } + + return JSON.stringify(metadataSettings); + } + +} diff --git a/src/core/features/h5p/classes/player.ts b/src/core/features/h5p/classes/player.ts new file mode 100644 index 000000000..0a473aef9 --- /dev/null +++ b/src/core/features/h5p/classes/player.ts @@ -0,0 +1,420 @@ +// (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 { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreXAPI } from '@features/xapi/services/xapi'; +import { CoreH5P } from '../services/h5p'; +import { CoreH5PCore, CoreH5PDisplayOptions, CoreH5PContentData, CoreH5PDependenciesFiles } from './core'; +import { CoreH5PCoreSettings, CoreH5PHelper } from './helper'; +import { CoreH5PStorage } from './storage'; + +/** + * Equivalent to Moodle's H5P player class. + */ +export class CoreH5PPlayer { + + constructor( + protected h5pCore: CoreH5PCore, + protected h5pStorage: CoreH5PStorage, + ) { } + + /** + * Calculate the URL to the site H5P player. + * + * @param siteUrl Site URL. + * @param fileUrl File URL. + * @param displayOptions Display options. + * @param component Component to send xAPI events to. + * @return URL. + */ + calculateOnlinePlayerUrl(siteUrl: string, fileUrl: string, displayOptions?: CoreH5PDisplayOptions, component?: string): string { + fileUrl = CoreH5P.instance.treatH5PUrl(fileUrl, siteUrl); + + const params = this.getUrlParamsFromDisplayOptions(displayOptions); + params.url = encodeURIComponent(fileUrl); + if (component) { + params.component = component; + } + + return CoreUrlUtils.instance.addParamsToUrl(CoreTextUtils.instance.concatenatePaths(siteUrl, '/h5p/embed.php'), params); + } + + /** + * Create the index.html to render an H5P package. + * Part of the code of this function is equivalent to Moodle's add_assets_to_page function. + * + * @param id Content ID. + * @param h5pUrl The URL of the H5P file. + * @param content Content data. + * @param embedType Embed type. The app will always use 'iframe'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the URL of the index file. + */ + async createContentIndex( + id: number, + h5pUrl: string, + content: CoreH5PContentData, + embedType: string, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const contentId = this.getContentId(id); + const basePath = CoreFile.instance.getBasePathInstant(); + const contentUrl = CoreFile.instance.convertFileSrc( + CoreTextUtils.instance.concatenatePaths( + basePath, + this.h5pCore.h5pFS.getContentFolderPath(content.folderName, site.getId()), + ), + ); + + // Create the settings needed for the content. + const contentSettings = { + library: CoreH5PCore.libraryToString(content.library), + fullScreen: content.library.fullscreen, + exportUrl: '', // We'll never display the download button, so we don't need the exportUrl. + embedCode: this.getEmbedCode(site.getURL(), h5pUrl, true), + resizeCode: this.getResizeCode(), + title: content.slug, + displayOptions: {}, + url: '', // It will be filled using dynamic params if needed. + contentUrl: contentUrl, + metadata: content.metadata, + contentUserData: [ + { + state: '{}', + }, + ], + }; + + // Get the core H5P assets, needed by the H5P classes to render the H5P content. + const result = await this.getAssets(id, content, embedType, site.getId()); + + result.settings.contents[contentId] = Object.assign(result.settings.contents[contentId], contentSettings); + + const indexPath = this.h5pCore.h5pFS.getContentIndexPath(content.folderName, site.getId()); + let html = '' + content.title + '' + + ''; + + // Include the required CSS. + result.cssRequires.forEach((cssUrl) => { + html += ''; + }); + + // Add the settings. + html += ''; + + // Add our own script to handle the params. + html += ''; + + html += ''; + + // Include the required JS at the beginning of the body, like Moodle web does. + // Load the embed.js to allow communication with the parent window. + html += ''; + + result.jsRequires.forEach((jsUrl) => { + html += ''; + }); + + html += '
      ' + + '' + + '
      '; + + const fileEntry = await CoreFile.instance.writeFile(indexPath, html); + + return fileEntry.toURL(); + } + + /** + * Delete all content indexes of all sites from filesystem. + * + * @return Promise resolved when done. + */ + async deleteAllContentIndexes(): Promise { + const siteIds = await CoreSites.instance.getSitesIds(); + + await Promise.all(siteIds.map((siteId) => this.deleteAllContentIndexesForSite(siteId))); + } + + /** + * Delete all content indexes for a certain site from filesystem. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteAllContentIndexesForSite(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + const records = await this.h5pCore.h5pFramework.getAllContentData(siteId); + + await Promise.all(records.map(async (record) => { + await CoreUtils.instance.ignoreErrors(this.h5pCore.h5pFS.deleteContentIndex(record.foldername, siteId!)); + })); + } + + /** + * Delete all package content data. + * + * @param fileUrl File URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentByUrl(fileUrl: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const data = await this.h5pCore.h5pFramework.getContentDataByUrl(fileUrl, siteId); + + await CoreUtils.instance.allPromises([ + this.h5pCore.h5pFramework.deleteContentData(data.id, siteId), + + this.h5pCore.h5pFS.deleteContentFolder(data.foldername, siteId), + ]); + } + + /** + * Get the assets of a package. + * + * @param id Content id. + * @param content Content data. + * @param embedType Embed type. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the assets. + */ + protected async getAssets( + id: number, + content: CoreH5PContentData, + embedType: string, + siteId?: string, + ): Promise<{settings: AssetsSettings; cssRequires: string[]; jsRequires: string[]}> { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get core assets. + const coreAssets = await CoreH5PHelper.getCoreAssets(siteId); + + const contentId = this.getContentId(id); + const settings = coreAssets.settings; + settings.contents = settings.contents || {}; + settings.contents[contentId] = settings.contents[contentId] || {}; + + settings.moodleLibraryPaths = await this.h5pCore.getDependencyRoots(id); + + /* The filterParameters function should be called before getting the dependency files because it rebuilds content + dependency cache. */ + settings.contents[contentId].jsonContent = await this.h5pCore.filterParameters(content, siteId); + + const files = await this.getDependencyFiles(id, content.folderName, siteId); + + // H5P checks the embedType in here, but we'll always use iframe so there's no need to do it. + // JavaScripts and stylesheets will be loaded through h5p.js. + settings.contents[contentId].scripts = this.h5pCore.getAssetsUrls(files.scripts); + settings.contents[contentId].styles = this.h5pCore.getAssetsUrls(files.styles); + + return { + settings: settings, + cssRequires: coreAssets.cssRequires, + jsRequires: coreAssets.jsRequires, + }; + } + + /** + * Get the identifier for the H5P content. This identifier is different than the ID stored in the DB. + * + * @param id Package ID. + * @return Content identifier. + */ + protected getContentId(id: number): string { + return 'cid-' + id; + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param displayOptions Display options. + * @param component Component to send xAPI events to. + * @param contextId Context ID where the H5P is. Required for tracking. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + async getContentIndexFileUrl( + fileUrl: string, + displayOptions?: CoreH5PDisplayOptions, + component?: string, + contextId?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const path = await this.h5pCore.h5pFS.getContentIndexFileUrl(fileUrl, siteId); + + // Add display options and component to the URL. + const data = await this.h5pCore.h5pFramework.getContentDataByUrl(fileUrl, siteId); + + displayOptions = this.h5pCore.fixDisplayOptions(displayOptions || {}, data.id); + + const params: Record = { + displayOptions: JSON.stringify(displayOptions), + component: component || '', + }; + + if (contextId) { + params.trackingUrl = await CoreXAPI.instance.getUrl(contextId, 'activity', siteId); + } + + return CoreUrlUtils.instance.addParamsToUrl(path, params); + } + + /** + * Finds library dependencies files of a certain package. + * + * @param id Content id. + * @param folderName Name of the folder of the content. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + protected async getDependencyFiles(id: number, folderName: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const preloadedDeps = await CoreH5P.instance.h5pCore.loadContentDependencies(id, 'preloaded', siteId); + + return this.h5pCore.getDependenciesFiles( + preloadedDeps, + folderName, + this.h5pCore.h5pFS.getExternalH5PFolderPath(siteId), + siteId, + ); + } + + /** + * Get display options from a URL params. + * + * @param params URL params. + * @return Display options as object. + */ + getDisplayOptionsFromUrlParams(params?: {[name: string]: string}): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + if (!params) { + return displayOptions; + } + + displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = false; // Never allow downloading in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] = false; // Never show the embed option in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = + CoreUtils.instance.isTrueOrOne(params[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT]); + displayOptions[CoreH5PCore.DISPLAY_OPTION_FRAME] = displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] || + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] || displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT]; + displayOptions[CoreH5PCore.DISPLAY_OPTION_ABOUT] = + !!this.h5pCore.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Embed code for settings. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @param embedEnabled Whether the option to embed the H5P content is enabled. + * @return The HTML code to reuse this H5P content in a different place. + */ + protected getEmbedCode(siteUrl: string, h5pUrl: string, embedEnabled?: boolean): string { + if (!embedEnabled) { + return ''; + } + + return ''; + } + + /** + * Get the encoded URL for embeding an H5P content. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @return The embed URL. + */ + protected getEmbedUrl(siteUrl: string, h5pUrl: string): string { + return CoreTextUtils.instance.concatenatePaths(siteUrl, '/h5p/embed.php') + '?url=' + h5pUrl; + } + + /** + * Resizing script for settings. + * + * @return The HTML code with the resize script. + */ + protected getResizeCode(): string { + return ''; + } + + /** + * Get the URL to the resizer script. + * + * @return URL. + */ + getResizerScriptUrl(): string { + return CoreTextUtils.instance.concatenatePaths(this.h5pCore.h5pFS.getCoreH5PPath(), 'js/h5p-resizer.js'); + } + + /** + * Get online player URL params from display options. + * + * @param options Display options. + * @return Object with URL params. + */ + getUrlParamsFromDisplayOptions(options?: CoreH5PDisplayOptions): {[name: string]: string} { + const params: {[name: string]: string} = {}; + + if (!options) { + return params; + } + + params[CoreH5PCore.DISPLAY_OPTION_FRAME] = options[CoreH5PCore.DISPLAY_OPTION_FRAME] ? '1' : '0'; + params[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = options[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] ? '1' : '0'; + params[CoreH5PCore.DISPLAY_OPTION_EMBED] = options[CoreH5PCore.DISPLAY_OPTION_EMBED] ? '1' : '0'; + params[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = options[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] ? '1' : '0'; + + return params; + } + +} + +type AssetsSettings = CoreH5PCoreSettings & { + contents: { + [contentId: string]: { + jsonContent: string | null; + scripts: string[]; + styles: string[]; + }; + }; + moodleLibraryPaths: { + [libString: string]: string; + }; +}; diff --git a/src/core/features/h5p/classes/storage.ts b/src/core/features/h5p/classes/storage.ts new file mode 100644 index 000000000..91b32aa07 --- /dev/null +++ b/src/core/features/h5p/classes/storage.ts @@ -0,0 +1,233 @@ +// (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 { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreH5PCore, CoreH5PLibraryBasicData } from './core'; +import { CoreH5PFramework } from './framework'; +import { CoreH5PMetadata } from './metadata'; +import { + CoreH5PLibrariesJsonData, + CoreH5PLibraryJsonData, + CoreH5PLibraryMetadataSettings, + CoreH5PMainJSONFilesData, +} from './validator'; + +/** + * Equivalent to H5P's H5PStorage class. + */ +export class CoreH5PStorage { + + constructor( + protected h5pCore: CoreH5PCore, + protected h5pFramework: CoreH5PFramework, + ) { } + + /** + * Save libraries. + * + * @param librariesJsonData Data about libraries. + * @param folderName Name of the folder of the H5P package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async saveLibraries(librariesJsonData: CoreH5PLibrariesJsonData, folderName: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // First of all, try to create the dir where the libraries are stored. This way we don't have to do it for each lib. + await CoreFile.instance.createDir(this.h5pCore.h5pFS.getLibrariesFolderPath(siteId)); + + const libraryIds: number[] = []; + + // Go through libraries that came with this package. + await Promise.all(Object.keys(librariesJsonData).map(async (libString) => { + const libraryData: CoreH5PLibraryBeingSaved = librariesJsonData[libString]; + + // Find local library identifier. + const dbData = await CoreUtils.instance.ignoreErrors(this.h5pFramework.getLibraryByData(libraryData)); + + if (dbData) { + // Library already installed. + libraryData.libraryId = dbData.id; + + const isNewPatch = await this.h5pFramework.isPatchedLibrary(libraryData, dbData); + + if (!isNewPatch) { + // Same or older version, no need to save. + libraryData.saveDependencies = false; + + return; + } + } + + libraryData.saveDependencies = true; + + // Convert metadataSettings values to boolean and json_encode it before saving. + libraryData.metadataSettings = libraryData.metadataSettings ? + CoreH5PMetadata.boolifyAndEncodeSettings( libraryData.metadataSettings) : undefined; + + // Save the library data in DB. + await this.h5pFramework.saveLibraryData(libraryData, siteId); + + // Now save it in FS. + try { + await this.h5pCore.h5pFS.saveLibrary(libraryData, siteId); + } catch (error) { + if (libraryData.libraryId) { + // An error occurred, delete the DB data because the lib FS data has been deleted. + await this.h5pFramework.deleteLibrary(libraryData.libraryId, siteId); + } + + throw error; + } + + if (typeof libraryData.libraryId != 'undefined') { + const promises: Promise[] = []; + + // Remove all indexes of contents that use this library. + promises.push(this.h5pCore.h5pFS.deleteContentIndexesForLibrary(libraryData.libraryId, siteId)); + + if (this.h5pCore.aggregateAssets) { + // Remove cached assets that use this library. + const removedEntries = await this.h5pFramework.deleteCachedAssets(libraryData.libraryId, siteId); + + await this.h5pCore.h5pFS.deleteCachedAssets(removedEntries, siteId); + } + + await CoreUtils.instance.allPromises(promises); + } + })); + + // Go through the libraries again to save dependencies. + await Promise.all(Object.keys(librariesJsonData).map(async (libString) => { + const libraryData: CoreH5PLibraryBeingSaved = librariesJsonData[libString]; + + if (!libraryData.saveDependencies || !libraryData.libraryId) { + return; + } + + const libId = libraryData.libraryId; + + libraryIds.push(libId); + + // Remove any old dependencies. + await this.h5pFramework.deleteLibraryDependencies(libId, siteId); + + // Insert the different new ones. + const promises: Promise[] = []; + + if (typeof libraryData.preloadedDependencies != 'undefined') { + promises.push(this.h5pFramework.saveLibraryDependencies(libId, libraryData.preloadedDependencies, 'preloaded')); + } + if (typeof libraryData.dynamicDependencies != 'undefined') { + promises.push(this.h5pFramework.saveLibraryDependencies(libId, libraryData.dynamicDependencies, 'dynamic')); + } + if (typeof libraryData.editorDependencies != 'undefined') { + promises.push(this.h5pFramework.saveLibraryDependencies(libId, libraryData.editorDependencies, 'editor')); + } + + await Promise.all(promises); + })); + + // Make sure dependencies, parameter filtering and export files get regenerated for content who uses these libraries. + if (libraryIds.length) { + await this.h5pFramework.clearFilteredParameters(libraryIds, siteId); + } + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async savePackage( + data: CoreH5PMainJSONFilesData, + folderName: string, + fileUrl: string, + skipContent?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.h5pCore.mayUpdateLibraries()) { + // Save the libraries that were processed. + await this.saveLibraries(data.librariesJsonData, folderName, siteId); + } + + const content: CoreH5PContentBeingSaved = {}; + + if (!skipContent) { + // Find main library version. + if (data.mainJsonData.preloadedDependencies) { + const mainLib = data.mainJsonData.preloadedDependencies.find((dependency) => + dependency.machineName === data.mainJsonData.mainLibrary); + + if (mainLib) { + const id = await this.h5pFramework.getLibraryIdByData(mainLib); + + content.library = Object.assign(mainLib, { libraryId: id }); + } + } + + content.params = JSON.stringify(data.contentJsonData); + + // Save the content data in DB. + await this.h5pCore.saveContent(content, folderName, fileUrl, siteId); + + // Save the content files in their right place in FS. + const destFolder = CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); + const contentPath = CoreTextUtils.instance.concatenatePaths(destFolder, 'content'); + + try { + await this.h5pCore.h5pFS.saveContent(contentPath, folderName, siteId); + } catch (error) { + // An error occurred, delete the DB data because the content files have been deleted. + await this.h5pFramework.deleteContentData(content.id!, siteId); + + throw error; + } + } + + return content; + } + +} + +/** + * Library to save. + */ +export type CoreH5PLibraryBeingSaved = Omit & { + libraryId?: number; // Library ID in the DB. + saveDependencies?: boolean; // Whether to save dependencies. + metadataSettings?: CoreH5PLibraryMetadataSettings | string; // Encoded metadata settings. +}; + +/** + * Data about a content being saved. + */ +export type CoreH5PContentBeingSaved = { + id?: number; + params?: string; + library?: CoreH5PContentLibrary; +}; + +export type CoreH5PContentLibrary = CoreH5PLibraryBasicData & { + libraryId?: number; // Library ID in the DB. +}; diff --git a/src/core/features/h5p/classes/validator.ts b/src/core/features/h5p/classes/validator.ts new file mode 100644 index 000000000..dc6994ef0 --- /dev/null +++ b/src/core/features/h5p/classes/validator.ts @@ -0,0 +1,328 @@ +// (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 { CoreFile, CoreFileFormat } from '@services/file'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreH5PSemantics } from './content-validator'; +import { CoreH5PCore, CoreH5PLibraryBasicData } from './core'; + +/** + * Equivalent to H5P's H5PValidator class. + */ +export class CoreH5PValidator { + + /** + * Get library data. + * This function won't validate most things because it should've been done by the server already. + * + * @param libDir Directory where the library files are. + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with library data. + */ + protected async getLibraryData(libDir: DirectoryEntry, libPath: string): Promise { + + // Read the required files. + const results = await Promise.all([ + this.readLibraryJsonFile(libPath), + this.readLibrarySemanticsFile(libPath), + this.readLibraryLanguageFiles(libPath), + this.libraryHasIcon(libPath), + ]); + + const libraryData: CoreH5PLibraryJsonData = results[0]; + libraryData.semantics = results[1]; + libraryData.language = results[2]; + libraryData.hasIcon = results[3]; + + return libraryData; + } + + /** + * Get library data for all libraries in an H5P package. + * + * @param packagePath The path to the package folder. + * @param entries List of files and directories in the root of the package folder. + * @retun Promise resolved with the libraries data. + */ + protected async getPackageLibrariesData( + packagePath: string, + entries: (DirectoryEntry | FileEntry)[], + ): Promise { + + const libraries: CoreH5PLibrariesJsonData = {}; + + await Promise.all(entries.map(async (entry) => { + if (entry.name[0] == '.' || entry.name[0] == '_' || entry.name == 'content' || entry.isFile) { + // Skip files, the content folder and any folder starting with a . or _. + return; + } + + const libDirPath = CoreTextUtils.instance.concatenatePaths(packagePath, entry.name); + + const libraryData = await this.getLibraryData( entry, libDirPath); + + libraryData.uploadDirectory = libDirPath; + libraries[CoreH5PCore.libraryToString(libraryData)] = libraryData; + })); + + return libraries; + } + + /** + * Check if the library has an icon file. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with boolean: whether the library has an icon file. + */ + protected async libraryHasIcon(libPath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'icon.svg'); + + try { + // Check if the file exists. + await CoreFile.instance.getFile(path); + + return true; + } catch (error) { + return false; + } + } + + /** + * Process libraries from an H5P library, getting the required data to save them. + * This code is inspired on the isValidPackage function in Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param packagePath The path to the package folder. + * @param entries List of files and directories in the root of the package folder. + * @return Promise resolved when done. + */ + async processH5PFiles(packagePath: string, entries: (DirectoryEntry | FileEntry)[]): Promise { + + // Read the needed files. + const results = await Promise.all([ + this.readH5PJsonFile(packagePath), + this.readH5PContentJsonFile(packagePath), + this.getPackageLibrariesData(packagePath, entries), + ]); + + return { + librariesJsonData: results[2], + mainJsonData: results[0], + contentJsonData: results[1], + }; + + } + + /** + * Read content.json file and return its parsed contents. + * + * @param packagePath The path to the package folder. + * @return Promise resolved with the parsed file contents. + */ + protected readH5PContentJsonFile(packagePath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(packagePath, 'content/content.json'); + + return CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } + + /** + * Read h5p.json file and return its parsed contents. + * + * @param packagePath The path to the package folder. + * @return Promise resolved with the parsed file contents. + */ + protected readH5PJsonFile(packagePath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(packagePath, 'h5p.json'); + + return CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } + + /** + * Read library.json file and return its parsed contents. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with the parsed file contents. + */ + protected readLibraryJsonFile(libPath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'library.json'); + + return CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } + + /** + * Read all language files and return their contents indexed by language code. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with the language data. + */ + protected async readLibraryLanguageFiles(libPath: string): Promise { + try { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'language'); + const langIndex: CoreH5PLibraryLangsJsonData = {}; + + // Read all the files in the language directory. + const entries = await CoreFile.instance.getDirectoryContents(path); + + await Promise.all(entries.map(async (entry) => { + const langFilePath = CoreTextUtils.instance.concatenatePaths(path, entry.name); + + try { + const langFileData = await CoreFile.instance.readFile( + langFilePath, + CoreFileFormat.FORMATJSON, + ); + + const parts = entry.name.split('.'); // The language code is in parts[0]. + langIndex[parts[0]] = langFileData; + } catch (error) { + // Ignore this language. + } + })); + + return langIndex; + + } catch (error) { + // Probably doesn't exist, ignore. + } + } + + /** + * Read semantics.json file and return its parsed contents. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with the parsed file contents. + */ + protected async readLibrarySemanticsFile(libPath: string): Promise { + try { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'semantics.json'); + + return await CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } catch (error) { + // Probably doesn't exist, ignore. + } + } + +} + +/** + * Data of the main JSON H5P files. + */ +export type CoreH5PMainJSONFilesData = { + contentJsonData: unknown; // Contents of content.json file. + librariesJsonData: CoreH5PLibrariesJsonData; // JSON data about each library. + mainJsonData: CoreH5PMainJSONData; // Contents of h5p.json file. +}; + +/** + * Data stored in h5p.json file of a content. More info in https://h5p.org/documentation/developers/json-file-definitions + */ +export type CoreH5PMainJSONData = { + title: string; // Title of the content. + mainLibrary: string; // The main H5P library for this content. + language: string; // Language code. + preloadedDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + embedTypes?: ('div' | 'iframe')[]; // List of possible ways to embed the package in the page. + authors?: { // The name and role of the content authors + name: string; + role: string; + }[]; + source?: string; // The source (a URL) of the licensed material. + license?: string; // A code for the content license. + licenseVersion?: string; // The version of the license above as a string. + licenseExtras?: string; // Any additional information about the license. + yearFrom?: string; // If a license is valid for a certain period of time, this represents the start year (as a string). + yearTo?: string; // If a license is valid for a certain period of time, this represents the end year (as a string). + changes?: { // The changelog. + date: string; + author: string; + log: string; + }[]; + authorComments?: string; // Comments for the editor of the content. +}; + +/** + * All JSON data for libraries of a package. + */ +export type CoreH5PLibrariesJsonData = {[libString: string]: CoreH5PLibraryJsonData}; + +/** + * All JSON data for a library, including semantics and language. + */ +export type CoreH5PLibraryJsonData = CoreH5PLibraryMainJsonData & { + semantics?: CoreH5PSemantics[]; // Data in semantics.json. + language?: CoreH5PLibraryLangsJsonData; // Language JSON data. + hasIcon?: boolean; // Whether the library has an icon. + uploadDirectory?: string; // Path where the lib is stored. +}; + +/** + * Data stored in library.json file of a library. More info in https://h5p.org/library-definition + */ +export type CoreH5PLibraryMainJsonData = { + title: string; // The human readable name of this library. + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + patchVersion: number; // Patch version. + runnable: number; // Whether or not this library is runnable. + coreApi?: { // Required version of H5P Core API. + majorVersion: number; + minorVersion: number; + }; + author?: string; // The name of the library author. + license?: string; // A code for the content license. + description?: string; // Textual description of the library. + preloadedDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + dynamicDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + editorDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + preloadedJs?: { path: string }[]; // List of path to the javascript files required for the library. + preloadedCss?: { path: string }[]; // List of path to the CSS files to be loaded with the library. + embedTypes?: ('div' | 'iframe')[]; // List of possible ways to embed the package in the page. + fullscreen?: number; // Enables the integrated full-screen button. + metadataSettings?: CoreH5PLibraryMetadataSettings; // Metadata settings. + addTo?: CoreH5PLibraryAddTo; +}; + +/** + * Library metadata settings. + */ +export type CoreH5PLibraryMetadataSettings = { + disable?: boolean | number; + disableExtraTitleField?: boolean | number; +}; + +/** + * Library plugin configuration data. + */ +export type CoreH5PLibraryAddTo = { + content?: { + types?: { + text?: { + regex?: string; + }; + }[]; + }; +}; + +/** + * Data stored in all languages JSON file of a library. + */ +export type CoreH5PLibraryLangsJsonData = {[code: string]: CoreH5PLibraryLangJsonData}; + +/** + * Data stored in each language JSON file of a library. + */ +export type CoreH5PLibraryLangJsonData = { + semantics?: CoreH5PSemantics[]; +}; diff --git a/src/core/features/h5p/h5p.module.ts b/src/core/features/h5p/h5p.module.ts new file mode 100644 index 000000000..e6fdab6e4 --- /dev/null +++ b/src/core/features/h5p/h5p.module.ts @@ -0,0 +1,51 @@ +// (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 { CORE_SITE_SCHEMAS } from '@services/sites'; +import { + CONTENT_TABLE_NAME, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, +} from './services/database/h5p'; + +@NgModule({ + imports: [ + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + CONTENT_TABLE_NAME, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, + ], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + // @todo + }, + }, + ], +}) +export class CoreH5PModule {} diff --git a/src/core/features/h5p/services/database/h5p.ts b/src/core/features/h5p/services/database/h5p.ts new file mode 100644 index 000000000..027e848a1 --- /dev/null +++ b/src/core/features/h5p/services/database/h5p.ts @@ -0,0 +1,308 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreH5PProvider service. + */ +// DB table names. +export const CONTENT_TABLE_NAME = 'h5p_content'; // H5P content. +export const LIBRARIES_TABLE_NAME = 'h5p_libraries'; // Installed libraries. +export const LIBRARY_DEPENDENCIES_TABLE_NAME = 'h5p_library_dependencies'; // Library dependencies. +export const CONTENTS_LIBRARIES_TABLE_NAME = 'h5p_contents_libraries'; // Which library is used in which content. +export const LIBRARIES_CACHEDASSETS_TABLE_NAME = 'h5p_libraries_cachedassets'; // H5P cached library assets. +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreH5PProvider', + version: 1, + canBeCleared: [ + CONTENT_TABLE_NAME, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, + ], + tables: [ + { + name: CONTENT_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'jsoncontent', + type: 'TEXT', + notNull: true, + }, + { + name: 'mainlibraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true, + }, + { + name: 'fileurl', + type: 'TEXT', + notNull: true, + }, + { + name: 'filtered', + type: 'TEXT', + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: LIBRARIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'machinename', + type: 'TEXT', + notNull: true, + }, + { + name: 'title', + type: 'TEXT', + notNull: true, + }, + { + name: 'majorversion', + type: 'INTEGER', + notNull: true, + }, + { + name: 'minorversion', + type: 'INTEGER', + notNull: true, + }, + { + name: 'patchversion', + type: 'INTEGER', + notNull: true, + }, + { + name: 'runnable', + type: 'INTEGER', + notNull: true, + }, + { + name: 'fullscreen', + type: 'INTEGER', + notNull: true, + }, + { + name: 'embedtypes', + type: 'TEXT', + notNull: true, + }, + { + name: 'preloadedjs', + type: 'TEXT', + }, + { + name: 'preloadedcss', + type: 'TEXT', + }, + { + name: 'droplibrarycss', + type: 'TEXT', + }, + { + name: 'semantics', + type: 'TEXT', + }, + { + name: 'addto', + type: 'TEXT', + }, + ], + }, + { + name: LIBRARY_DEPENDENCIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'requiredlibraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true, + }, + ], + }, + { + name: CONTENTS_LIBRARIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'h5pid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true, + }, + { + name: 'dropcss', + type: 'INTEGER', + notNull: true, + }, + { + name: 'weight', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: LIBRARIES_CACHEDASSETS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'hash', + type: 'TEXT', + notNull: true, + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true, + }, + ], + }, + ], +}; + +/** + * Structure of content data stored in DB. + */ +export type CoreH5PContentDBRecord = { + id: number; // The id of the content. + jsoncontent: string; // The content in json format. + mainlibraryid: number; // The library we first instantiate for this node. + foldername: string; // Name of the folder that contains the contents. + fileurl: string; // The online URL of the H5P package. + filtered: string | null; // Filtered version of json_content. + timecreated: number; // Time created. + timemodified: number; // Time modified. +}; + +/** + * Structure of library data stored in DB. + */ +export type CoreH5PLibraryDBRecord = { + id: number; // The id of the library. + machinename: string; // The library machine name. + title: string; // The human readable name of this library. + majorversion: number; // Major version. + minorversion: number; // Minor version. + patchversion: number; // Patch version. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedtypes: string; // List of supported embed types. + preloadedjs?: string | null; // Comma separated list of scripts to load. + preloadedcss?: string | null; // Comma separated list of stylesheets to load. + droplibrarycss?: string | null; // Libraries that should not have CSS included if this lib is used. Comma separated list. + semantics?: string | null; // The semantics definition. + addto?: string | null; // Plugin configuration data. +}; + +/** + * Structure of library dependencies stored in DB. + */ +export type CoreH5PLibraryDependencyDBRecord = { + id: number; // Id. + libraryid: number; // The id of an H5P library. + requiredlibraryid: number; // The dependent library to load. + dependencytype: string; // Type: preloaded, dynamic, or editor. +}; + +/** + * Structure of library used by a content stored in DB. + */ +export type CoreH5PContentsLibraryDBRecord = { + id: number; + h5pid: number; + libraryid: number; + dependencytype: string; + dropcss: number; + weight: number; +}; + +/** + * Structure of library cached assets stored in DB. + */ +export type CoreH5PLibraryCachedAssetsDBRecord = { + id: number; // Id. + libraryid: number; // The id of an H5P library. + hash: string; // The hash to identify the cached asset. + foldername: string; // Name of the folder that contains the contents. +}; diff --git a/src/core/features/h5p/services/h5p.ts b/src/core/features/h5p/services/h5p.ts new file mode 100644 index 000000000..bb3106fc6 --- /dev/null +++ b/src/core/features/h5p/services/h5p.ts @@ -0,0 +1,248 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreWSExternalWarning, CoreWSExternalFile } from '@services/ws'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreQueueRunner } from '@classes/queue-runner'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; + +import { CoreH5PCore } from '../classes/core'; +import { CoreH5PFramework } from '../classes/framework'; +import { CoreH5PPlayer } from '../classes/player'; +import { CoreH5PStorage } from '../classes/storage'; +import { CoreH5PValidator } from '../classes/validator'; + +import { makeSingleton } from '@singletons'; +import { CoreError } from '@classes/errors/error'; + +/** + * Service to provide H5P functionalities. + */ +@Injectable({ providedIn: 'root' }) +export class CoreH5PProvider { + + h5pCore: CoreH5PCore; + h5pFramework: CoreH5PFramework; + h5pPlayer: CoreH5PPlayer; + h5pStorage: CoreH5PStorage; + h5pValidator: CoreH5PValidator; + queueRunner: CoreQueueRunner; + + protected readonly ROOT_CACHE_KEY = 'CoreH5P:'; + + constructor() { + this.queueRunner = new CoreQueueRunner(1); + + this.h5pValidator = new CoreH5PValidator(); + this.h5pFramework = new CoreH5PFramework(); + this.h5pCore = new CoreH5PCore(this.h5pFramework); + this.h5pStorage = new CoreH5PStorage(this.h5pCore, this.h5pFramework); + this.h5pPlayer = new CoreH5PPlayer(this.h5pCore, this.h5pStorage); + } + + /** + * Returns whether or not WS to get trusted H5P file is available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.8 + */ + async canGetTrustedH5PFile(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canGetTrustedH5PFileInSite(site); + } + + /** + * Returns whether or not WS to get trusted H5P file is available in a certain site. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.8 + */ + canGetTrustedH5PFileInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.wsAvailable('core_h5p_get_trusted_h5p_file')); + } + + /** + * Get a trusted H5P file. + * + * @param url The file URL. + * @param options Options. + * @param ignoreCache Whether to ignore cache. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file data. + */ + async getTrustedH5PFile( + url: string, + options?: CoreH5PGetTrustedFileOptions, + ignoreCache?: boolean, + siteId?: string, + ): Promise { + + options = options || {}; + + const site = await CoreSites.instance.getSite(siteId); + + const data: CoreH5pGetTrustedH5pFileWSParams = { + url: this.treatH5PUrl(url, site.getURL()), + frame: options.frame ? 1 : 0, + export: options.export ? 1 : 0, + embed: options.embed ? 1 : 0, + copyright: options.copyright ? 1 : 0, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getTrustedH5PFileCacheKey(url), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const result: CoreH5PGetTrustedH5PFileResult = await site.read('core_h5p_get_trusted_h5p_file', data, preSets); + + if (result.warnings && result.warnings.length) { + throw result.warnings[0]; + } + + if (result.files && result.files.length) { + return result.files[0]; + } + + throw new CoreError('File not found'); + } + + /** + * Get cache key for trusted H5P file WS calls. + * + * @param url The file URL. + * @return Cache key. + */ + protected getTrustedH5PFileCacheKey(url: string): string { + return this.getTrustedH5PFilePrefixCacheKey() + url; + } + + /** + * Get prefixed cache key for trusted H5P file WS calls. + * + * @return Cache key. + */ + protected getTrustedH5PFilePrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; + } + + /** + * Invalidates all trusted H5P file WS calls. + * + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllGetTrustedH5PFile(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getTrustedH5PFilePrefixCacheKey()); + } + + /** + * Invalidates get trusted H5P file WS call. + * + * @param url The URL of the file. + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateGetTrustedH5PFile(url: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getTrustedH5PFileCacheKey(url)); + } + + /** + * Check whether H5P offline is disabled. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether is disabled. + */ + async isOfflineDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isOfflineDisabledInSite(site); + } + + /** + * Check whether H5P offline is disabled. + * + * @param site Site instance. If not defined, current site. + * @return Whether is disabled. + */ + isOfflineDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.isFeatureDisabled('NoDelegate_H5POffline')); + } + + /** + * Treat an H5P url before sending it to WS. + * + * @param url H5P file URL. + * @param siteUrl Site URL. + * @return Treated url. + */ + treatH5PUrl(url: string, siteUrl: string): string { + if (url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, '/webservice/pluginfile.php')) === 0) { + url = url.replace('/webservice/pluginfile', '/pluginfile'); + } + + return CoreUrlUtils.instance.removeUrlParams(url); + } + +} + +export class CoreH5P extends makeSingleton(CoreH5PProvider) {} + +/** + * Params of core_h5p_get_trusted_h5p_file WS. + */ +export type CoreH5pGetTrustedH5pFileWSParams = { + url: string; // H5P file url. + frame?: number; // The frame allow to show the bar options below the content. + export?: number; // The export allow to download the package. + embed?: number; // The embed allow to copy the code to your site. + copyright?: number; // The copyright option. +}; + +/** + * Options for core_h5p_get_trusted_h5p_file. + */ +export type CoreH5PGetTrustedFileOptions = { + frame?: boolean; // Whether to show the bar options below the content. + export?: boolean; // Whether to allow to download the package. + embed?: boolean; // Whether to allow to copy the code to your site. + copyright?: boolean; // The copyright option. +}; + +/** + * Result of core_h5p_get_trusted_h5p_file. + */ +export type CoreH5PGetTrustedH5PFileResult = { + files: CoreWSExternalFile[]; // Files. + warnings: CoreWSExternalWarning[]; // List of warnings. +}; diff --git a/src/core/services/file.ts b/src/core/services/file.ts index 30f015ae9..5f48fa2e8 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -70,10 +70,25 @@ export const enum CoreFileFormat { export class CoreFileProvider { // Formats to read a file. + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATTEXT = CoreFileFormat.FORMATTEXT; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATDATAURL = CoreFileFormat.FORMATDATAURL; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATBINARYSTRING = CoreFileFormat.FORMATBINARYSTRING; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATARRAYBUFFER = CoreFileFormat.FORMATARRAYBUFFER; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATJSON = CoreFileFormat.FORMATJSON; // Folders. @@ -460,19 +475,25 @@ export class CoreFileProvider { * @param format Format to read the file. * @return Promise to be resolved when the file is read. */ - readFile(path: string, format: CoreFileFormat = CoreFileProvider.FORMATTEXT): Promise { + readFile( + path: string, + format?: CoreFileFormat.FORMATTEXT | CoreFileFormat.FORMATDATAURL | CoreFileFormat.FORMATBINARYSTRING, + ): Promise; + readFile(path: string, format: CoreFileFormat.FORMATARRAYBUFFER): Promise; + readFile(path: string, format: CoreFileFormat.FORMATJSON): Promise; + readFile(path: string, format: CoreFileFormat = CoreFileFormat.FORMATTEXT): Promise { // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); this.logger.debug('Read file ' + path + ' with format ' + format); switch (format) { - case CoreFileProvider.FORMATDATAURL: + case CoreFileFormat.FORMATDATAURL: return File.instance.readAsDataURL(this.basePath, path); - case CoreFileProvider.FORMATBINARYSTRING: + case CoreFileFormat.FORMATBINARYSTRING: return File.instance.readAsBinaryString(this.basePath, path); - case CoreFileProvider.FORMATARRAYBUFFER: + case CoreFileFormat.FORMATARRAYBUFFER: return File.instance.readAsArrayBuffer(this.basePath, path); - case CoreFileProvider.FORMATJSON: + case CoreFileFormat.FORMATJSON: return File.instance.readAsText(this.basePath, path).then((text) => { const parsed = CoreTextUtils.instance.parseJSON(text, null); @@ -494,8 +515,8 @@ export class CoreFileProvider { * @param format Format to read the file. * @return Promise to be resolved when the file is read. */ - readFileData(fileData: IFile, format: CoreFileFormat = CoreFileProvider.FORMATTEXT): Promise { - format = format || CoreFileProvider.FORMATTEXT; + readFileData(fileData: IFile, format: CoreFileFormat = CoreFileFormat.FORMATTEXT): Promise { + format = format || CoreFileFormat.FORMATTEXT; this.logger.debug('Read file from file data with format ' + format); return new Promise((resolve, reject): void => { @@ -503,7 +524,7 @@ export class CoreFileProvider { reader.onloadend = (event): void => { if (event.target?.result !== undefined && event.target.result !== null) { - if (format == CoreFileProvider.FORMATJSON) { + if (format == CoreFileFormat.FORMATJSON) { // Convert to object. const parsed = CoreTextUtils.instance.parseJSON( event.target.result, null); @@ -535,13 +556,13 @@ export class CoreFileProvider { }, 3000); switch (format) { - case CoreFileProvider.FORMATDATAURL: + case CoreFileFormat.FORMATDATAURL: reader.readAsDataURL(fileData); break; - case CoreFileProvider.FORMATBINARYSTRING: + case CoreFileFormat.FORMATBINARYSTRING: reader.readAsBinaryString(fileData); break; - case CoreFileProvider.FORMATARRAYBUFFER: + case CoreFileFormat.FORMATARRAYBUFFER: reader.readAsArrayBuffer(fileData); break; default: diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index 366b5e6fe..cf82d0c22 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -23,7 +23,7 @@ import { timeout } from 'rxjs/operators'; import { CoreNativeToAngularHttpResponse } from '@classes/native-to-angular-http'; import { CoreApp } from '@services/app'; -import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreFile, CoreFileFormat } from '@services/file'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; @@ -855,9 +855,9 @@ export class CoreWSProvider { // Use the cordova plugin. if (url.indexOf('file://') === 0) { // We cannot load local files using the http native plugin. Use file provider instead. - const format = options.responseType == 'json' ? CoreFileProvider.FORMATJSON : CoreFileProvider.FORMATTEXT; - - const content = await CoreFile.instance.readFile(url, format); + const content = options.responseType == 'json' ? + await CoreFile.instance.readFile(url, CoreFileFormat.FORMATJSON) : + await CoreFile.instance.readFile(url, CoreFileFormat.FORMATTEXT); return new HttpResponse({ body: content, From 0ead40c72ed896c7c59f3a385bfb017c05b9e5c6 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 16 Dec 2020 14:46:06 +0100 Subject: [PATCH 4/7] MOBILE-3666 h5p: Implement pluginfile handler --- src/core/features/h5p/h5p.module.ts | 4 +- .../h5p/services/handlers/pluginfile.ts | 173 ++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/core/features/h5p/services/handlers/pluginfile.ts diff --git a/src/core/features/h5p/h5p.module.ts b/src/core/features/h5p/h5p.module.ts index e6fdab6e4..803599a5a 100644 --- a/src/core/features/h5p/h5p.module.ts +++ b/src/core/features/h5p/h5p.module.ts @@ -14,6 +14,7 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CorePluginFileDelegate } from '@services/plugin-file-delegate'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { CONTENT_TABLE_NAME, @@ -22,6 +23,7 @@ import { CONTENTS_LIBRARIES_TABLE_NAME, LIBRARIES_CACHEDASSETS_TABLE_NAME, } from './services/database/h5p'; +import { CoreH5PPluginFileHandler } from './services/handlers/pluginfile'; @NgModule({ imports: [ @@ -43,7 +45,7 @@ import { multi: true, deps: [], useFactory: () => () => { - // @todo + CorePluginFileDelegate.instance.registerHandler(CoreH5PPluginFileHandler.instance); }, }, ], diff --git a/src/core/features/h5p/services/handlers/pluginfile.ts b/src/core/features/h5p/services/handlers/pluginfile.ts new file mode 100644 index 000000000..05b816b35 --- /dev/null +++ b/src/core/features/h5p/services/handlers/pluginfile.ts @@ -0,0 +1,173 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { FileEntry } from '@ionic-native/file'; + +import { CoreFilepoolOnProgressCallback } from '@services/filepool'; +import { CorePluginFileDownloadableResult, CorePluginFileHandler } from '@services/plugin-file-delegate'; +import { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreH5P } from '../h5p'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreH5PHelper } from '../../classes/helper'; + +/** + * Handler to treat H5P files. + */ +@Injectable({ providedIn: 'root' }) +export class CoreH5PPluginFileHandlerService implements CorePluginFileHandler { + + name = 'CoreH5PPluginFileHandler'; + + /** + * React to a file being deleted. + * + * @param fileUrl The file URL used to download the file. + * @param path The path of the deleted file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async fileDeleted(fileUrl: string, path: string, siteId?: string): Promise { + // If an h5p file is deleted, remove the contents folder. + await CoreH5P.instance.h5pPlayer.deleteContentByUrl(fileUrl, siteId); + } + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + async getDownloadableFile(file: CoreWSExternalFile, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (site.containsUrl(file.fileurl) && file.fileurl.match(/pluginfile\.php\/[^/]+\/core_h5p\/export\//i)) { + // It's already a deployed file, use it. + return file; + } + + return CoreH5P.instance.getTrustedH5PFile(file.fileurl, {}, false, siteId); + } + + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return List of URLs. + */ + getDownloadableFilesFromHTML(container: HTMLElement): string[] { + const iframes = Array.from(container.querySelectorAll('iframe.h5p-iframe')); + const urls: string[] = []; + + for (let i = 0; i < iframes.length; i++) { + const params = CoreUrlUtils.instance.extractUrlParams(iframes[i].src); + + if (params.url) { + urls.push(params.url); + } + } + + return urls; + } + + /** + * Get a file size. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the size. + */ + async getFileSize(file: CoreWSExternalFile, siteId?: string): Promise { + try { + const trustedFile = await this.getDownloadableFile(file, siteId); + + return trustedFile.filesize || 0; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // WS returned an error, it means it cannot be downloaded. + return 0; + } + + throw error; + } + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return CoreH5P.instance.canGetTrustedH5PFileInSite(); + } + + /** + * Check if a file is downloadable. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with a boolean and a reason why it isn't downloadable if needed. + */ + async isFileDownloadable(file: CoreWSExternalFile, siteId?: string): Promise { + const offlineDisabled = await CoreH5P.instance.isOfflineDisabled(siteId); + + if (offlineDisabled) { + return { + downloadable: false, + reason: Translate.instance.instant('core.h5p.offlinedisabled'), + }; + } else { + return { + downloadable: true, + }; + } + } + + /** + * Check whether the file should be treated by this handler. It is used in functions where the component isn't used. + * + * @param file The file data. + * @return Whether the file should be treated by this handler. + */ + shouldHandleFile(file: CoreWSExternalFile): boolean { + return CoreMimetypeUtils.instance.guessExtensionFromUrl(file.fileurl) == 'h5p'; + } + + /** + * Treat a downloaded file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + treatDownloadedFile( + fileUrl: string, + file: FileEntry, + siteId?: string, + onProgress?: CoreFilepoolOnProgressCallback, + ): Promise { + return CoreH5PHelper.saveH5P(fileUrl, file, siteId, onProgress); + } + +} + +export class CoreH5PPluginFileHandler extends makeSingleton(CoreH5PPluginFileHandlerService) {} From 7956d8e563dc280fa9432705bae292a9a4da4230 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 16 Dec 2020 16:04:43 +0100 Subject: [PATCH 5/7] MOBILE-3666 h5p: Implement H5P components --- .../services/handlers/displayh5p.ts | 39 ++- src/assets/img/icons/h5p.svg | 14 ++ .../h5p/components/components.module.ts | 44 ++++ .../h5p-iframe/core-h5p-iframe.html | 5 + .../h5p/components/h5p-iframe/h5p-iframe.ts | 223 ++++++++++++++++++ .../h5p-player/core-h5p-player.html | 14 ++ .../h5p/components/h5p-player/h5p-player.scss | 48 ++++ .../h5p/components/h5p-player/h5p-player.ts | 219 +++++++++++++++++ src/core/features/h5p/h5p.module.ts | 2 + src/core/services/utils/url.ts | 2 +- 10 files changed, 589 insertions(+), 21 deletions(-) create mode 100644 src/assets/img/icons/h5p.svg create mode 100644 src/core/features/h5p/components/components.module.ts create mode 100644 src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html create mode 100644 src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts create mode 100644 src/core/features/h5p/components/h5p-player/core-h5p-player.html create mode 100644 src/core/features/h5p/components/h5p-player/h5p-player.scss create mode 100644 src/core/features/h5p/components/h5p-player/h5p-player.ts diff --git a/src/addons/filter/displayh5p/services/handlers/displayh5p.ts b/src/addons/filter/displayh5p/services/handlers/displayh5p.ts index 67a07b92c..e4ac253c8 100644 --- a/src/addons/filter/displayh5p/services/handlers/displayh5p.ts +++ b/src/addons/filter/displayh5p/services/handlers/displayh5p.ts @@ -17,7 +17,7 @@ import { Injectable, ViewContainerRef, ComponentFactoryResolver } from '@angular import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter'; import { CoreFilterFilter, CoreFilterFormatTextOptions } from '@features/filter/services/filter'; import { makeSingleton } from '@singletons'; -// @todo import { CoreH5PPlayerComponent } from '@core/h5p/components/h5p-player/h5p-player'; +import { CoreH5PPlayerComponent } from '@features/h5p/components/h5p-player/h5p-player'; /** * Handler to support the Display H5P filter. @@ -80,32 +80,31 @@ export class AddonFilterDisplayH5PHandlerService extends CoreFilterDefaultHandle * @return If async, promise resolved when done. */ handleHtml( - container: HTMLElement, // eslint-disable-line @typescript-eslint/no-unused-vars - filter: CoreFilterFilter, // eslint-disable-line @typescript-eslint/no-unused-vars - options: CoreFilterFormatTextOptions, // eslint-disable-line @typescript-eslint/no-unused-vars - viewContainerRef: ViewContainerRef, // eslint-disable-line @typescript-eslint/no-unused-vars - component?: string, // eslint-disable-line @typescript-eslint/no-unused-vars - componentId?: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + container: HTMLElement, + filter: CoreFilterFilter, + options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, + component?: string, + componentId?: string | number, siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars ): void | Promise { - // @todo - // const placeholders = Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder')); + const placeholders = Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder')); - // placeholders.forEach((placeholder) => { - // const url = placeholder.getAttribute('data-player-src'); + placeholders.forEach((placeholder) => { + const url = placeholder.getAttribute('data-player-src') || ''; - // Create the component to display the player. - // const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent); - // const componentRef = viewContainerRef.createComponent(factory); + // Create the component to display the player. + const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent); + const componentRef = viewContainerRef.createComponent(factory); - // componentRef.instance.src = url; - // componentRef.instance.component = component; - // componentRef.instance.componentId = componentId; + componentRef.instance.src = url; + componentRef.instance.component = component; + componentRef.instance.componentId = componentId; - // // Move the component to its right position. - // placeholder.parentElement?.replaceChild(componentRef.instance.elementRef.nativeElement, placeholder); - // }); + // Move the component to its right position. + placeholder.parentElement?.replaceChild(componentRef.instance.elementRef.nativeElement, placeholder); + }); } } diff --git a/src/assets/img/icons/h5p.svg b/src/assets/img/icons/h5p.svg new file mode 100644 index 000000000..7856f9efb --- /dev/null +++ b/src/assets/img/icons/h5p.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/core/features/h5p/components/components.module.ts b/src/core/features/h5p/components/components.module.ts new file mode 100644 index 000000000..e7842c6f6 --- /dev/null +++ b/src/core/features/h5p/components/components.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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreH5PPlayerComponent } from './h5p-player/h5p-player'; +import { CoreH5PIframeComponent } from './h5p-iframe/h5p-iframe'; + +@NgModule({ + declarations: [ + CoreH5PPlayerComponent, + CoreH5PIframeComponent, + ], + imports: [ + CommonModule, + IonicModule, + CoreDirectivesModule, + TranslateModule.forChild(), + CoreComponentsModule, + ], + providers: [ + ], + exports: [ + CoreH5PPlayerComponent, + CoreH5PIframeComponent, + ], +}) +export class CoreH5PComponentsModule {} diff --git a/src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html b/src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html new file mode 100644 index 000000000..943c32102 --- /dev/null +++ b/src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts new file mode 100644 index 000000000..0bcb55350 --- /dev/null +++ b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts @@ -0,0 +1,223 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, ElementRef, OnChanges, SimpleChange, EventEmitter, OnDestroy } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreConstants } from '@/core/constants'; +import { CoreSite } from '@classes/site'; +import { CoreLogger } from '@singletons/logger'; +import { CoreH5PCore, CoreH5PDisplayOptions } from '../../classes/core'; +import { CoreH5PHelper } from '../../classes/helper'; + +/** + * Component to render an iframe with an H5P package. + */ +@Component({ + selector: 'core-h5p-iframe', + templateUrl: 'core-h5p-iframe.html', +}) +export class CoreH5PIframeComponent implements OnChanges, OnDestroy { + + @Input() fileUrl?: string; // The URL of the H5P file. If not supplied, onlinePlayerUrl is required. + @Input() displayOptions?: CoreH5PDisplayOptions; // Display options. + @Input() onlinePlayerUrl?: string; // The URL of the online player to display the H5P package. + @Input() trackComponent?: string; // Component to send xAPI events to. + @Input() contextId?: number; // Context ID. Required for tracking. + @Output() onIframeUrlSet = new EventEmitter<{src: string; online: boolean}>(); + @Output() onIframeLoaded = new EventEmitter(); + + iframeSrc?: string; + + protected site: CoreSite; + protected siteId: string; + protected siteCanDownload: boolean; + protected logger: CoreLogger; + protected currentPageRoute?: string; + protected subscription: Subscription; + protected iframeLoadedOnce = false; + + constructor( + public elementRef: ElementRef, + router: Router, + ) { + + this.logger = CoreLogger.getInstance('CoreH5PIframeComponent'); + this.site = CoreSites.instance.getCurrentSite()!; + this.siteId = this.site.getId(); + this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.instance.isOfflineDisabledInSite(); + + // Send resize events when the page holding this component is re-entered. + // @todo: Check that this works as expected. + this.currentPageRoute = router.url; + this.subscription = router.events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { + if (!this.iframeLoadedOnce || event.urlAfterRedirects == this.currentPageRoute) { + return; + } + + window.dispatchEvent(new Event('resize')); + }); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + // If it's already playing don't change it. + if ((changes.fileUrl || changes.onlinePlayerUrl) && !this.iframeSrc) { + this.play(); + } + } + + /** + * Play the H5P. + * + * @return Promise resolved when done. + */ + protected async play(): Promise { + let localUrl: string | undefined; + let state: string; + + if (this.fileUrl) { + state = await CoreFilepool.instance.getFileStateByUrl(this.siteId, this.fileUrl); + } else { + state = CoreConstants.NOT_DOWNLOADABLE; + } + + if (this.siteCanDownload && CoreFileHelper.instance.isStateDownloaded(state)) { + // Package is downloaded, use the local URL. + localUrl = await this.getLocalUrl(); + } + + try { + if (localUrl) { + // Local package. + this.iframeSrc = localUrl; + } else { + this.onlinePlayerUrl = this.onlinePlayerUrl || CoreH5P.instance.h5pPlayer.calculateOnlinePlayerUrl( + this.site.getURL(), + this.fileUrl || '', + this.displayOptions, + this.trackComponent, + ); + + // Never allow downloading in the app. This will only work if the user is allowed to change the params. + const src = this.onlinePlayerUrl.replace( + CoreH5PCore.DISPLAY_OPTION_DOWNLOAD + '=1', + CoreH5PCore.DISPLAY_OPTION_DOWNLOAD + '=0', + ); + + // Get auto-login URL so the user is automatically authenticated. + const url = await this.site.getAutoLoginUrl(src, false); + + // Add the preventredirect param so the user can authenticate. + this.iframeSrc = CoreUrlUtils.instance.addParamsToUrl(url, { preventredirect: false }); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading H5P package.', true); + + } finally { + this.addResizerScript(); + this.onIframeUrlSet.emit({ src: this.iframeSrc!, online: !!localUrl }); + } + } + + /** + * Get the local URL of the package. + * + * @return Promise resolved with the local URL. + */ + protected async getLocalUrl(): Promise { + try { + const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl( + this.fileUrl!, + this.displayOptions, + this.trackComponent, + this.contextId, + this.siteId, + ); + + return url; + } catch (error) { + // Index file doesn't exist, probably deleted because a lib was updated. Try to create it again. + try { + const path = await CoreFilepool.instance.getInternalUrlByUrl(this.siteId, this.fileUrl!); + + const file = await CoreFile.instance.getFile(path); + + await CoreH5PHelper.saveH5P(this.fileUrl!, file, this.siteId); + + // File treated. Try to get the index file URL again. + const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl( + this.fileUrl!, + this.displayOptions, + this.trackComponent, + this.contextId, + this.siteId, + ); + + return url; + } catch (error) { + // Still failing. Delete the H5P package? + this.logger.error('Error loading downloaded index:', error, this.fileUrl); + } + } + } + + /** + * Add the resizer script if it hasn't been added already. + */ + protected addResizerScript(): void { + if (document.head.querySelector('#core-h5p-resizer-script') != null) { + // Script already added, don't add it again. + return; + } + + const script = document.createElement('script'); + script.id = 'core-h5p-resizer-script'; + script.type = 'text/javascript'; + script.src = CoreH5P.instance.h5pPlayer.getResizerScriptUrl(); + document.head.appendChild(script); + } + + /** + * H5P iframe has been loaded. + */ + iframeLoaded(): void { + this.onIframeLoaded.emit(); + this.iframeLoadedOnce = true; + + // Send a resize event to the window so H5P package recalculates the size. + window.dispatchEvent(new Event('resize')); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } + +} diff --git a/src/core/features/h5p/components/h5p-player/core-h5p-player.html b/src/core/features/h5p/components/h5p-player/core-h5p-player.html new file mode 100644 index 000000000..cb510fd5d --- /dev/null +++ b/src/core/features/h5p/components/h5p-player/core-h5p-player.html @@ -0,0 +1,14 @@ +
      + + + + +
      + + +
      +
      + + + diff --git a/src/core/features/h5p/components/h5p-player/h5p-player.scss b/src/core/features/h5p/components/h5p-player/h5p-player.scss new file mode 100644 index 000000000..6e1803220 --- /dev/null +++ b/src/core/features/h5p/components/h5p-player/h5p-player.scss @@ -0,0 +1,48 @@ +:host { + --core-h5p-placeholder-bg-color: var(--gray); + --core-h5p-placeholder-text-color: var(--ion-text-color); + + .core-h5p-placeholder { + position: relative; + width: 100%; + height: 230px; + background: url('../../../../../assets/img/icons/h5p.svg') center top 25px / 100px auto no-repeat var(--core-h5p-placeholder-bg-color); + color: var(--core-h5p-placeholder-text-color); + + .icon:not([color="success"]) { + color: var(--core-h5p-placeholder-text-color); + } + + .core-h5p-placeholder-play-button, .core-h5p-placeholder-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .core-h5p-placeholder-play-button { + font-size: 30px; + min-height: 50px; + } + + .core-h5p-placeholder-download-container { + position: absolute; + top: 0; + right: 0; + + ion-spinner { + margin-right: 0.75em; + } + + core-download-refresh > ion-icon { + margin: 0.4rem 0.2rem; + padding: 0 0.5em; + line-height: .67; + } + } + + ion-spinner circle { + stroke: var(--core-h5p-placeholder-text-color); + } + } +} diff --git a/src/core/features/h5p/components/h5p-player/h5p-player.ts b/src/core/features/h5p/components/h5p-player/h5p-player.ts new file mode 100644 index 000000000..27830dc58 --- /dev/null +++ b/src/core/features/h5p/components/h5p-player/h5p-player.ts @@ -0,0 +1,219 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, ElementRef, OnInit, OnDestroy, OnChanges, SimpleChange } from '@angular/core'; + +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CorePluginFileDelegate } from '@services/plugin-file-delegate'; +import { CoreConstants } from '@/core/constants'; +import { CoreSite } from '@classes/site'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreH5PDisplayOptions } from '../../classes/core'; + +/** + * Component to render an H5P package. + */ +@Component({ + selector: 'core-h5p-player', + templateUrl: 'core-h5p-player.html', + styleUrls: ['h5p-player.scss'], +}) +export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { + + @Input() src?: string; // The URL of the player to display the H5P package. + @Input() component?: string; // Component. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. + + showPackage = false; + state?: string; + canDownload = false; + calculating = true; + displayOptions?: CoreH5PDisplayOptions; + urlParams?: {[name: string]: string}; + + protected site: CoreSite; + protected siteId: string; + protected siteCanDownload: boolean; + protected observer?: CoreEventObserver; + protected logger: CoreLogger; + + constructor( + public elementRef: ElementRef, + ) { + + this.logger = CoreLogger.getInstance('CoreH5PPlayerComponent'); + this.site = CoreSites.instance.getCurrentSite()!; + this.siteId = this.site.getId(); + this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.instance.isOfflineDisabledInSite(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.checkCanDownload(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + // If it's already playing there's no need to check if it can be downloaded. + if (changes.src && !this.showPackage) { + this.checkCanDownload(); + } + } + + /** + * Play the H5P. + * + * @param e Event. + */ + async play(e: MouseEvent): Promise { + e.preventDefault(); + e.stopPropagation(); + + this.displayOptions = CoreH5P.instance.h5pPlayer.getDisplayOptionsFromUrlParams(this.urlParams); + this.showPackage = true; + + if (!this.canDownload || (this.state != CoreConstants.OUTDATED && this.state != CoreConstants.NOT_DOWNLOADED)) { + return; + } + + // Download the package in background if the size is low. + try { + this.attemptDownloadInBg(); + } catch (error) { + this.logger.error('Error downloading H5P in background', error); + } + } + + /** + * Download the package. + * + * @return Promise resolved when done. + */ + async download(): Promise { + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); + + return; + } + + try { + // Get the file size and ask the user to confirm. + const size = await CorePluginFileDelegate.instance.getFileSize({ fileurl: this.urlParams!.url }, this.siteId); + + await CoreDomUtils.instance.confirmDownloadSize({ size: size, total: true }); + + // User confirmed, add to the queue. + await CoreFilepool.instance.addToQueueByUrl(this.siteId, this.urlParams!.url, this.component, this.componentId); + + } catch (error) { + if (CoreDomUtils.instance.isCanceledError(error)) { + // User cancelled, stop. + return; + } + + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + this.calculateState(); + } + } + + /** + * Download the H5P in background if the size is low. + * + * @return Promise resolved when done. + */ + protected async attemptDownloadInBg(): Promise { + if (!this.urlParams || !this.src || !this.siteCanDownload || !CoreH5P.instance.canGetTrustedH5PFileInSite() || + !CoreApp.instance.isOnline()) { + return; + } + + // Get the file size. + const size = await CorePluginFileDelegate.instance.getFileSize({ fileurl: this.urlParams.url }, this.siteId); + + if (CoreFilepool.instance.shouldDownload(size)) { + // Download the file in background. + CoreFilepool.instance.addToQueueByUrl(this.siteId, this.urlParams.url, this.component, this.componentId); + } + } + + /** + * Check if the package can be downloaded. + * + * @return Promise resolved when done. + */ + protected async checkCanDownload(): Promise { + this.observer && this.observer.off(); + this.urlParams = CoreUrlUtils.instance.extractUrlParams(this.src || ''); + + if (this.src && this.siteCanDownload && CoreH5P.instance.canGetTrustedH5PFileInSite() && this.site.containsUrl(this.src)) { + this.calculateState(); + + // Listen for changes in the state. + try { + const eventName = await CoreFilepool.instance.getFileEventNameByUrl(this.siteId, this.urlParams.url); + + this.observer = CoreEvents.on(eventName, () => { + this.calculateState(); + }); + } catch (error) { + // An error probably means the file cannot be downloaded or we cannot check it (offline). + } + + } else { + this.calculating = false; + this.canDownload = false; + } + + } + + /** + * Calculate state of the file. + * + * @param fileUrl The H5P file URL. + * @return Promise resolved when done. + */ + protected async calculateState(): Promise { + this.calculating = true; + + // Get the status of the file. + try { + const state = await CoreFilepool.instance.getFileStateByUrl(this.siteId, this.urlParams!.url); + + this.canDownload = true; + this.state = state; + } catch (error) { + this.canDownload = false; + } finally { + this.calculating = false; + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.observer?.off(); + } + +} diff --git a/src/core/features/h5p/h5p.module.ts b/src/core/features/h5p/h5p.module.ts index 803599a5a..011fd98ad 100644 --- a/src/core/features/h5p/h5p.module.ts +++ b/src/core/features/h5p/h5p.module.ts @@ -16,6 +16,7 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { CorePluginFileDelegate } from '@services/plugin-file-delegate'; import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreH5PComponentsModule } from './components/components.module'; import { CONTENT_TABLE_NAME, LIBRARIES_TABLE_NAME, @@ -27,6 +28,7 @@ import { CoreH5PPluginFileHandler } from './services/handlers/pluginfile'; @NgModule({ imports: [ + CoreH5PComponentsModule, ], providers: [ { diff --git a/src/core/services/utils/url.ts b/src/core/services/utils/url.ts index 3d19bcb83..428cbce48 100644 --- a/src/core/services/utils/url.ts +++ b/src/core/services/utils/url.ts @@ -55,7 +55,7 @@ export class CoreUrlUtilsProvider { * @param boolToNumber Whether to convert bools to 1 or 0. * @return URL with params. */ - addParamsToUrl(url: string, params?: CoreUrlParams, anchor?: string, boolToNumber?: boolean): string { + addParamsToUrl(url: string, params?: Record, anchor?: string, boolToNumber?: boolean): string { let separator = url.indexOf('?') != -1 ? '&' : '?'; for (const key in params) { From 6e98c436511a1b16ccae16c5089e7a533bba9bc2 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 17 Dec 2020 08:44:06 +0100 Subject: [PATCH 6/7] MOBILE-3666 h5p: Enable player reporting --- src/core/features/h5p/assets/moodle/js/params.js | 3 +++ src/core/features/h5p/classes/player.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/core/features/h5p/assets/moodle/js/params.js b/src/core/features/h5p/assets/moodle/js/params.js index 87722aacd..75c73d6ba 100644 --- a/src/core/features/h5p/assets/moodle/js/params.js +++ b/src/core/features/h5p/assets/moodle/js/params.js @@ -39,6 +39,9 @@ if (window.H5PIntegration && window.H5PIntegration.contents && location.search) } } else if (nameAndValue[0] == 'component') { window.H5PIntegration.moodleComponent = nameAndValue[1]; + if (window.H5PIntegration.moodleComponent) { + window.H5PIntegration.reportingIsEnabled = true; + } } else if (nameAndValue[0] == 'trackingUrl' && contentData) { contentData.url = nameAndValue[1]; } diff --git a/src/core/features/h5p/classes/player.ts b/src/core/features/h5p/classes/player.ts index 0a473aef9..db77e3d0b 100644 --- a/src/core/features/h5p/classes/player.ts +++ b/src/core/features/h5p/classes/player.ts @@ -226,6 +226,8 @@ export class CoreH5PPlayer { settings.moodleLibraryPaths = await this.h5pCore.getDependencyRoots(id); + // The Moodle component is added dynamically using the params.js script instead of doing it here. + /* The filterParameters function should be called before getting the dependency files because it rebuilds content dependency cache. */ settings.contents[contentId].jsonContent = await this.h5pCore.filterParameters(content, siteId); From 06c5153e0e228a01ddab508da64aa200a14c291c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 17 Dec 2020 08:44:28 +0100 Subject: [PATCH 7/7] MOBILE-3666 h5p: Store library metadata in database --- src/core/features/h5p/classes/framework.ts | 9 ++++-- src/core/features/h5p/classes/metadata.ts | 6 +++- src/core/features/h5p/classes/storage.ts | 2 +- .../features/h5p/services/database/h5p.ts | 30 ++++++++++++++++++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/core/features/h5p/classes/framework.ts b/src/core/features/h5p/classes/framework.ts index 47cdea7ae..d10eeafcd 100644 --- a/src/core/features/h5p/classes/framework.ts +++ b/src/core/features/h5p/classes/framework.ts @@ -40,7 +40,8 @@ import { import { CoreError } from '@classes/errors/error'; import { CoreH5PSemantics } from './content-validator'; import { CoreH5PContentBeingSaved, CoreH5PLibraryBeingSaved } from './storage'; -import { CoreH5PLibraryAddTo } from './validator'; +import { CoreH5PLibraryAddTo, CoreH5PLibraryMetadataSettings } from './validator'; +import { CoreH5PMetadata } from './metadata'; /** * Equivalent to Moodle's implementation of H5PFrameworkInterface. @@ -625,6 +626,7 @@ export class CoreH5PFramework { return Object.assign(library, { semantics: library.semantics ? CoreTextUtils.instance.parseJSON(library.semantics, null) : null, addto: library.addto ? CoreTextUtils.instance.parseJSON(library.addto, null) : null, + metadatasettings: library.metadatasettings ? CoreTextUtils.instance.parseJSON(library.metadatasettings, null) : null, }); } @@ -712,6 +714,8 @@ export class CoreH5PFramework { droplibrarycss: dropLibraryCSS, semantics: typeof libraryData.semantics != 'undefined' ? JSON.stringify(libraryData.semantics) : null, addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, + metadatasettings: typeof libraryData.metadataSettings != 'undefined' ? + CoreH5PMetadata.boolifyAndEncodeSettings(libraryData.metadataSettings) : null, }; if (libraryData.libraryId) { @@ -898,9 +902,10 @@ export type CoreH5PFrameworkContentData = { metadata: unknown; // Content metadata. }; -export type CoreH5PLibraryParsedDBRecord = Omit & { +export type CoreH5PLibraryParsedDBRecord = Omit & { semantics: CoreH5PSemantics[] | null; addto: CoreH5PLibraryAddTo | null; + metadatasettings: CoreH5PLibraryMetadataSettings | null; }; type LibraryDependency = { diff --git a/src/core/features/h5p/classes/metadata.ts b/src/core/features/h5p/classes/metadata.ts index 69c44054c..359b0af26 100644 --- a/src/core/features/h5p/classes/metadata.ts +++ b/src/core/features/h5p/classes/metadata.ts @@ -26,7 +26,11 @@ export class CoreH5PMetadata { * @param metadataSettings Settings. * @return Stringified settings. */ - static boolifyAndEncodeSettings(metadataSettings: CoreH5PLibraryMetadataSettings): string { + static boolifyAndEncodeSettings(metadataSettings: CoreH5PLibraryMetadataSettings | string): string { + if (typeof metadataSettings == 'string') { + return metadataSettings; + } + // Convert metadataSettings values to boolean. if (typeof metadataSettings.disable != 'undefined') { metadataSettings.disable = metadataSettings.disable === 1; diff --git a/src/core/features/h5p/classes/storage.ts b/src/core/features/h5p/classes/storage.ts index 91b32aa07..049e2efab 100644 --- a/src/core/features/h5p/classes/storage.ts +++ b/src/core/features/h5p/classes/storage.ts @@ -77,7 +77,7 @@ export class CoreH5PStorage { // Convert metadataSettings values to boolean and json_encode it before saving. libraryData.metadataSettings = libraryData.metadataSettings ? - CoreH5PMetadata.boolifyAndEncodeSettings( libraryData.metadataSettings) : undefined; + CoreH5PMetadata.boolifyAndEncodeSettings(libraryData.metadataSettings) : undefined; // Save the library data in DB. await this.h5pFramework.saveLibraryData(libraryData, siteId); diff --git a/src/core/features/h5p/services/database/h5p.ts b/src/core/features/h5p/services/database/h5p.ts index 027e848a1..0564cdb75 100644 --- a/src/core/features/h5p/services/database/h5p.ts +++ b/src/core/features/h5p/services/database/h5p.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { SQLiteDB } from '@classes/sqlitedb'; import { CoreSiteSchema } from '@services/sites'; /** @@ -19,7 +20,7 @@ import { CoreSiteSchema } from '@services/sites'; */ // DB table names. export const CONTENT_TABLE_NAME = 'h5p_content'; // H5P content. -export const LIBRARIES_TABLE_NAME = 'h5p_libraries'; // Installed libraries. +export const LIBRARIES_TABLE_NAME = 'h5p_libraries_2'; // Installed libraries. export const LIBRARY_DEPENDENCIES_TABLE_NAME = 'h5p_library_dependencies'; // Library dependencies. export const CONTENTS_LIBRARIES_TABLE_NAME = 'h5p_contents_libraries'; // Which library is used in which content. export const LIBRARIES_CACHEDASSETS_TABLE_NAME = 'h5p_libraries_cachedassets'; // H5P cached library assets. @@ -148,6 +149,10 @@ export const SITE_SCHEMA: CoreSiteSchema = { name: 'addto', type: 'TEXT', }, + { + name: 'metadatasettings', + type: 'TEXT', + }, ], }, { @@ -239,6 +244,28 @@ export const SITE_SCHEMA: CoreSiteSchema = { ], }, ], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async migrate(db: SQLiteDB, oldVersion: number, siteId: string): Promise { + if (oldVersion >= 2) { + return; + } + + const newTable = LIBRARIES_TABLE_NAME; + const oldTable = 'h5p_libraries'; + + try { + await db.tableExists(oldTable); + + // Move the records from the old table. + const entries = await db.getAllRecords(oldTable); + + await Promise.all(entries.map((entry) => db.insertRecord(newTable, entry))); + + await db.dropTable(oldTable); + } catch { + // Old table does not exist, ignore. + } + }, }; /** @@ -273,6 +300,7 @@ export type CoreH5PLibraryDBRecord = { droplibrarycss?: string | null; // Libraries that should not have CSS included if this lib is used. Comma separated list. semantics?: string | null; // The semantics definition. addto?: string | null; // Plugin configuration data. + metadatasettings?: string | null; // Metadata settings. }; /**