sidekiq-cron-0.6.3/0000755000004100000410000000000013124502443014136 5ustar www-datawww-datasidekiq-cron-0.6.3/Rakefile0000644000004100000410000000311113124502443015577 0ustar www-datawww-data# encoding: utf-8 require 'rubygems' require 'bundler/setup' require 'bundler' begin Bundler.setup(:default, :development) rescue Bundler::BundlerError => e $stderr.puts e.message $stderr.puts "Run `bundle install` to install missing gems" exit e.status_code end require 'rake' require 'jeweler' Jeweler::Tasks.new do |gem| # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options gem.name = "sidekiq-cron" gem.homepage = "http://github.com/ondrejbartas/sidekiq-cron" gem.license = "MIT" gem.summary = %Q{Sidekiq Cron helps to add repeated scheduled jobs} gem.description = %Q{Enables to set jobs to be run in specified time (using CRON notation)} gem.email = "ondrej@bartas.cz" gem.authors = ["Ondrej Bartas"] # dependencies defined in Gemfile end Jeweler::RubygemsDotOrgTasks.new #TESTING task :doc do system 'sdoc -N .' end require 'rake/testtask' task :default => :test Rake::TestTask.new(:test) do |t| t.test_files = FileList['test/functional/**/*_test.rb', 'test/unit/**/*_test.rb','test/integration/**/*_test.rb'] t.warning = false t.verbose = false end namespace :test do Rake::TestTask.new(:unit) do |t| t.test_files = FileList['test/unit/**/*_test.rb'] t.warning = false t.verbose = false end Rake::TestTask.new(:functional) do |t| t.test_files = FileList['test/functional/**/*_test.rb'] t.warning = false t.verbose = false end Rake::TestTask.new(:integration) do |t| t.test_files = FileList['test/integration/**/*_test.rb'] t.warning = false t.verbose = false end end sidekiq-cron-0.6.3/Gemfile0000644000004100000410000000067113124502443015435 0ustar www-datawww-datasource 'https://rubygems.org' gem 'sidekiq', '>= 4.2.1' gem 'rufus-scheduler', '>= 3.3.0' group :development do gem 'bundler' gem 'simplecov' gem 'redis-namespace', '>= 1.5.2' gem 'shoulda-context' gem 'rack' gem 'rack-test' gem 'jeweler' gem 'minitest' gem 'test-unit' gem 'sdoc' # sdoc -N . gem 'slim' gem 'sinatra' gem 'mocha' gem 'coveralls' gem 'shotgun' gem 'guard' gem 'guard-minitest' end sidekiq-cron-0.6.3/examples/0000755000004100000410000000000013124502443015754 5ustar www-datawww-datasidekiq-cron-0.6.3/examples/web-cron-ui.png0000644000004100000410000045315413124502443020625 0ustar www-datawww-dataPNG  IHDRc7iCCPICC ProfileX yuXT]>gab.AzhFI%D$D@QLDE0QD@PD>^75g^{]k\W0#qTGs#wO+lmhooq?iX {/ ?W@uGSIGBE Dpw`/1™L@\@ z:J$RMz!|LDD.@ L&L&Ұ %6:#"<ׯ sF~%Ḿ@K8#J3YΓx x.><|#,z ۅ`Ec8"E;5?$6<%%?lozG"=py`NB9:~Ν#~~D5m!q!Gu }DQ,؆RZDH]35q" tF,Z!Z*"7Y > LC0NRC 2PF2_NF@IAQ쬽;:ZS!GBΏ[~,7 S =_xjWdA #J@ h` . <d Ddˠ>p<#)x wA"B'BҐA y@P0 CP>TUBgFA$0 Y`^X 5`Cv`8NBv~?O (6 J2FByPTT*UA]@u#cq 5Z@hfZ폎A Еt;z=~^Doa4F cqc19RL= 3w,ˆǪ#݋-Vc[7ljvȸ8\׌~hihh?wgLⴺδhi_~%BMB .HtRtt^tttnM}%bD'1XHl$"&gOo€ge0dÐPp#Qј̘XqIiSSS=9$F2%I[if01?~:A,8%K(K>K 0"+UՕ5:M͒-eqGW99 898Z9r4 < %ukk[۟;2sGǑg/O- /9o4o->6>P^y~f~=~  <gׅą\Z^ k  /؊9/\/!"Z&zGtUL\MX؜8xyD }'XI 0j)XJU*DJ4,&M~,єy&K'k( {^\\yyOcwT^((͔ӕ;TUUNL2ڪTWTSW]PWQU?LE^@&FH3]GsMKM+N_ڲaMs:::u:ӺBd3Szzz5  f % C )QڌVSoLMLMI.̈́̂Λ-5i8f߲̒rJ*jɺ զ-}i'jiױ U땽}5CGE}w|;9q~"fV6.˃t\m/Uqoq${{Cum "אW,N- 0(  , * .) YS*)KBWv w o$EEDE%E=Ήъ9HBޱq,!w(^"@W"sgSREۿOp_澷gҠ4t L̰̇Y YEYȞ>`~|}5A탧Q *VnW/(/Xp #jGN<:~LXCSQJtmq{@I^ɷ>*-/*)8ZQRʨωգ' N^8{*Ӕgϴ׈Քbkj?Թ9q>~\乩džF&#盽GZLZ:/^8֚\֗h\pU6涼v=}#cӣqUWvw5kz{^?Kۛݻ}# }}>/nz200EZ_/W2kh}qf\o*WW^GÚڝn?g7p囒[[/#T yAA,C"G;7@A0 SrN* r`Qet>F3ƅҘh 0D/`He:Czvu7oI[Y1 Y);VYz9+ Jʱ*TުiiqiKh{&4v7n1gjaߥh`hds#_/@5P*?9N6IdV>L%q}K(KJJNLyuߡT4tzdn], ٯ͜=w7`!\|t#G/,/>.WPX\RZzBZ٩g&jY zGKii2dfW}u‚mC)LO##ݣԢI17űq񄳉I,)fKI֝~8#41ZrJ'o~?ttZ1DzI}*'N8}LghYzs mϛ[L/qիw;u(lYӀ vw? )?z̡c4 AQ8tF s#ӮuW01a*"U2eig}%mCWA_ЌgeE4!/yVj,$;*W'`(4\&OhhfijKio%[5N3q53Ø4jgkfMi͵FV CcwnawxYxys'ךN`">8&D K/EtFfDE㣇b V4qYz IrIu)>{>WjeXdfgM}@UNCzybo6)9y,Z] Jcbʣ+"+)U'Nڜ>|fYzs6 Mmڲj~қ+W:N]/z^k{0]{ I=q}<>ded 3l9?audapyur'ߑ X5ΚZ& m@8k@ף% c?EK( : "7ֆ}p< Eq Q! ;4&гV)&ɺ&tXCl"v'Ľ Hm! Q: 3D18M@ǠPx t1̹,,WL&أ8h889s~ʓ+߄S[0EHO#Hh8IĀdTtR~doSSɫeӨFͰ草im .`e6 v} C(N߻r9z &z9xe +(fEH #y:4G[BsdFm$G$XXXXY'Y9<9af.n 1205 532 z,@IDATxUϴsν7@ Kh#`CQPba۞ς>ɳ`AT@.!$!@)73ݳw%'!!k}ٳoYYkd8u("NT*`_xYiCV4ۏ=t("l:8ikX/("i"pwC^s4oZPE`SFߔ;}WE@ E,j|\ ~9mAI*"l: E@P7?$]KArhCCkVE@P->NiE@P(^\Bl4^6I$t("lj8q.ԺUE@Kz!tCɊČ4LfH,;GE@PE`SC@;UE@ xA%B7,?TIZiiE@PE`S@K^KPE`DG"k@ao;8i6CPE@3ARE@0PqMĸj6FPE@3֯{[NtZY:64]{f٭'E\6ѡ9?bNK̲[^>Ӗ֠("v[m^hzg)"(5fX}=\)I[)d75s}c=s}hQ٧R^EsO~Svf%mCWԒ"(k<7Q+"(;Nk~'9];tz66w._z!k])婓{kxW6>Nȍp+{zz&#/_=?d+Vlf]]].)K{vXWԚE@PZ/};`e.&'+vEgYNgIO>"s'S&vяR~/zыgOYsNR9o߸^{IGʵ6lRfgyŋ2ss?G#떞s^(-YC ?O>,{׽uFkaE@PE@p-S:EQjFA-xFf[7aYи|!1v[$r{Om+i>Bv^ 3#)+%/Ϲm&n馿oy[Ν˰gwO~򓝝mV}}}r!D f11!f?#A?T##LC&/ɘaxkk~6;!]x2ٙ5'B;F)}/_\?g}6Gx ⑈>v޼yνaE)ٳIo /}"( 8UO_=&Dyo毼R' :~Ȣ>8vl-Iϕ7?ZB>{W;waϒ_q MCpܱI\Ȇ4\KzꩨAq*DLjsE֔B:}0(a9wUh B*fo\4xn|DBfc=( wג=f}O<WyꩧhUz3$.1lZZNy-F* nX˹ n+8 ;7(n:-!4eQDgRt]4a[c_IӅO&9#6_=Pbr8J#pBXtq*\k}3M&_7F l-d2)/϶ay=,3dB [? a d[D>jsO7ST.AI}|bCɭi(}|~3.$e 'p>1~(~ﴤ"("A`-Bg/i?n7nN3w_}}CmZҕY\wJ~rH-].#]1=;ٲx(Z?S^ITJE+B|kp.*H1xQLAb=Q w7i ~8g,;3 U$Hl! #T$i!2&tXPRɧ?iP^HlVϽ{"O%-IBHvҍҝ)-?H[;iT?# #u w &|@Z_rjsǾ7&&3hK^&$NQ'bcioy=Rio {\޵R!if`0O:$#O\ iƆAF(Co֠("0_׮D\V/?wXxⱘCI^\S]Fc2d7~YhKsռ'irCSs:zHX+ISb r-)Z% pB=,V #1IF1#Uiu6](57ѪPx"(Z ;M #JDr-|ΎH>Hwds_OHn{ 000#2zGI.iq(]:G:Q&Iv/Q BN\77/\o7P<(& ~fIeZLhIE@PE`cG`cQ 0L|_~y?]}@V!n9(alVV+n;)G'rNWpʝړXnC6 o7 *аv!aL&peA̒V-,m;X ɅFRI_ 8`tpcrF4CZnb1!Ӑ2\ B0 6rQGY0 ok^ X7wD2Bywc_ivams=jStsǸ|#`"dJ`9׫mM[HY'}TnK_tZ2$h Z.$ۄԽ"(""vDoKI6r7y)(/%.cK9]nu칶<9$Vz{ێ?4]mBg,ʰ jo#u-R؆un.?Y'߼ʈW Lh Ft$'X߀0oB֔vO¦9#rѳ!aG=\|xyHw\s\Ds[Ts$pƹBP!# 뒠1aJH==yΦ7R;UXLTxvA/yUlmtEQEIѣrQrh-YuI"ETņ"U*b1Im<RԞd#lv z!V;δW$4j|&=qKԟWr%99&˶~͗fk|n˯wOǐ )BSך`}gq: ٔo8G>0G:!\ "CoO@C›ĸbL=*1v9Spsկ~L (R`\|hh`~DKOZ644kF*9dۣm$'pc`wy:q!tRvi!Ɂᘟ4 6KwPHw kT~[D[*Htd{w blѷ~6칬TT:`hL׷-Niz01-#4L=T|(k$dGvn?֓i{:ʘ 2v]mk}&X75+b= ܓ3M$'> eZ-2ԠiE@PE`Ac5z}ߑVozv(֟^'~˿mk_dQ,[Tγo,\}hS'n%+ykvm{OՆ3vL[G}Kvy[_sE=]H~s/G+Wl̝+#=ȶ{o>ՙS6[qݽK/Ɣt/'Ypm^mC!H>%ď2 C =2Q;]ӣGOSEBڣ6p9 {u('~9 V>NN. #2-D:+& lK%OIt">:k[ zRRR[@۝(?0"= #u # 0Cm(Yn4LjQА# ;Fh iCÕH TȉZ8beFx fZLs) BA(JU^ZR{3s9ET^E@P*-pB/_nÙoOv|JE/BH2sS^ Wӷx{#3Yﱯ4EMkiNif ̲~P=W8 ~/?e"A&tV[N:c>1ذaHQ" =:ʡi|% >sc/ˆ;f8KBcAHGc}9֔Ja!F_PE@o?ȲS{t̞N{{dڂg 5G+z >K&X[}R[oA}-nwO~U)"05?*?*"(FajkF8cju0 ?2{:p|3@N%.1x"lז+"0>ظ (V)"ާ?>ڌŸ\Wk}ԯu*""JBj'WS6\Zv\M("kbp Za/Qb_;mʡ{nfE@PE@PE@ <ȁ4ޟ)K1.eB-C!c#l! 53gt+"(/T{7"("("(@SLqRQdi&|XJ8 qJ^[/w.nL,OW䡛\7_Gg%Flľ::5qL:3u.Jžμ@QaR^R :A}^5F>s =2Tf`$ Uf2E^ڥi躆Dr#-9Y:aMIǁ[L+ys00zW\$7IZ *W,-7y6?S9 Y)s< qI{Z8_ ud 5KB- r4Q+ "9:tl *3`VTAPa< Β%KOi$*{ Zwp248|QM5`=u+n_oO͕?idQ^`f%R@8q^%%?*p_dl%~qfcH`&rsV7gu1Jxh\JLבz,nbΈX>x]߃*3̠ʢ*P8tҤU;(uAF)@AVnג_E",םr5'tI{za)e R!JU%ϨU?3Vw+qM,j&!)@K mfѮPT>M%c~ JRUtb5x`鸲x(ye$ٮ!2W*_,Z(T}qgٲe`,ڴ je2%Iƪ<)&hyfagn/ TS'MJ|i) e4rJ^U&pRw'𥉛R(JAeQo!FIEG9OC-Au JjWAgȘI'1*Ǡ=l=)u/CiU~n{n%%0j ԓs0Ɗ8v"_5JnGbhU9+}84AeTRYTVܞn8ʕ+m7&$qrw2Q%|ژQųU'|}pXj2.g5!E%EQ4&f8qcq4ɲJet}^wwuL z*>Lu8Қjq:ؚf::l/nðcoX f[8iX9h }^]߃X*3HH*_YHTO+qVZeݰ >`䕽( 3)AjR7TJ z}QկuI{|oxnǢClj+8ts񠵄: H\GJVv˔{I*ևM[-mH JqsPW#=!ٮ=VA+rs$EUnqq+RV^ JS8||[\KpR:Ɏ3 L+䔟ZR%;j+I1(-q!3? m[|gIXr4E2I,{@ʇ1G8'M3\ (5 گX)V2$븲P>t P@(/T*3X4Tf2h(7.^4+b\ W(7αQ&n'nBKM J\g+Y\(b"*A/VMXե4cSۼo^cMcQg?)s-=.B1UE1YP`Jۊ{JWvR =g;B߃2<$UfTfPar&bks;R hf$aeDN@e^XouDfRa_rbFH gSZ)6(68&!LΨ^< {S%1~p&WLjW5.u8vr_-5(VI1O2lb5,,f*V2lb5,,f*V2lb5,,f*V2lb5,,f V.&ʼ0C QA),s$ Cq[~t+ a1@n☸R3^j$bF> PA^T] @z,g쓸e23\(IV3ꑹ`ĵԤ*ka 9$A+{X"0+1Vl::y%A28̠2ʢ@h٫0eL;  >q!r^) yߗ~ Sbz(2N\XgjHC*)D.9cVK#C( dq+ĵ㾄TBIy H:{ þJ9/ժ2Mk"ieB,{jCRt\9sB+g{I߃Uf@QA+EUn9 3Վ%'r6@v5~Zab\JBazk  k :IsC>W Maub$Vװe5A30c,,;e]2512a^ #j%eM Cr'guy&i7>QOI2޳MjbL gUfTReQ_H^B"2ѶAdCiZ-N%G{0| 3(w$q] A+i!W~Æ4aogu?-ėL_PT;֪^=MF_ P0z&lVfmr+@Pt\}Bg>ۋp4TAe|PJeQ7 9D%LcbJ'yIԇqlZOȏZ[rJ4u$,gpa9Nnwf4I%] d.r%́,FYyd&JBرSV$P<2K吟RJq\M>cAT2WHBeQ@P}8βgS/ aqpײz9XT3ȓ◹5 J>(²c"9|N历H+ŐI, $h6&8ij4URuj0(SRm^-D7E֒ոbeBfU;+C'+}UfP +QG߃*30TfPJeCU}#8&aFZL$np[X˕A6Tg\Y A:ްUREE?m=8J h]9Lk*S/VxʝUjqePko$6Q!ðV.WiP(V+AuJL}g$Tf̠2 v:0e2(3?+OϐEiqܠ{$,$&N!Q`oP,\`ќ;@S,Xz~")IӬmC2cKkfae!L,`-\Ŗ<(8JɼF(V::+}q`6}̠2W< T͟*o \61]iaV)7E}`zNPIz$"i'7%FQ %SedܟXhf~w98~jdBNJyEI>Hn5BnCYZ1eNMV1~FE=a(kD*ǡ1 +W2^>t<TfPLEUn'~Fq$y!Dhf=ߌ\C&0ѪpМQa}L7,V-NkݕJi*1V\ \%18x1dUlAI ٥1MuL$1F%wkؗڪ6}LJXjBX :# +}3 =h*3|e TUar1ĥ'>`qƒFy/KB+zl@qN*YT{fXgO a(5r> ly.ԏ+&xq=5^uI3՞%,:-G3% 53fBeaup U Us`j"a}^!v}̠2SQe  a $Qe,B˘6ʽ$ Lu%a"[0R4tjE&uY[0+59M5DR)֡iFVM82ⷆKՁޠkf$ˇqvr,}Š()VrHjd(RZb5 8-@FXN!ŪQ~*VrHjdCrWprZǩI nZFCaX&ptԉ0,/hsJaX=ĢqE&ME s M'uPT/XBI-efqc1?^Uw;|+V+Y+}{Pe*3Q*_,PJ ؈dAð#P8k=ɜ=X|MiB*3G *3̐?ǿ`xQ$(W: 94 9  m4-q]C3AMm ĵ81qH e)Cőahaj |Q-fd8񩌃oWBSU$YB%2F]a&DCMV 4+W:gUfPA+n,;Qqo{ ^S8Xleh]Kq!rV a2洊2hJaT?oz^2{(Ȍ_5ʷOMC$rf;ǘWXAʕ4ԃlf{ƏWP \r,ŌQ=R4b,$9h"g*3`-WM@TUA%38+ 5°ωJAWt0An"S_EiTEÆf#dpz=U*+[|B ()W| bFlN-f )@28 |ɬC)3Mo:I&*WۗKxau0Q,f*bQ`y*V yJc@`Ѭ}Cݲg3k J9!u&eǭelUwP2 $ ޏHȡW*䰞KB'ffAZKI(DԊexZ_V3J! FijzI#Tn%}n` ?V)XYHϤ|tP:^)Vc`u\qaeѵ}}}W|ݮԜle\3sG8Î+EBe74o˨\(i] xίA( +*7쥆40[YXm944~fX،*cq]5܄/i #ꆇT?9;FIԘ8Q*k1av:Ub*`]YXo0վuW)L⻖>?bʚa dM?8AȈi\)V"PיQbXg+W:P98s~8[ [YSYJ3؋Y^LE(I][qzWaG'HD^|_gX(WNjNI^A(WBtS8IIVdv5~ݸ!#jEquaOgyݥ]KfZݳGV,{t23Ų++bT1&YǕKńbeƚAW2y^)V n߃:t\ (^3Cui!Er@~vȢ9\g: LXw5|]c%+Xi겺퟼8 ;dab2Q.B*n$u3cz&A$nD<!'6Y(T Ky!6V:ؼ+V`~}!k%ƒָd" >b:Y!Dv{>X`%rOMBըJj*Vc`?u\qP98s8EsopF8#A 0_EGܮFH 9ez&e92|=1Nbʄi鑉n݊so4Ĕp,zXν@Dm(2̂(~%('?! k2k\/i܈Ru3U׭Cs36)v6zT*~Mr'W?(3fd2.yU+^ [QCo8AgٲAB¾ĭ`& :yR\2.I #/Y&7J| c ]Oz^fG륬#95w3ؠR2&1F (A0 +sMb6cTT5R-h3$5БWTҜ>(cjd}bc1$)F\Z` 02ZrP%|1lxfڻ&Yg-@l:>] @IDATo{ޖ$X(EjqXsPRdz]Ǖ+W,`~Dv.xjIn7C XGDL Q b)鹍YGֈ~aTYgYiDmjhY:4cZ\zV ,3,J0EGfAʼnӆ)-Nuw??[ TI*LA͡,!b.X7F Bj X8Xw42.̖Ar\(-Qg6˯IWKMb`vZ8!?ٰX폢G.j\NVZЀ㺹A:aqX?+j}Wb>ރ:t\bl@YA#$o4{ܹ_kiI?%~a_{G[+vBQ9\Abns\VDzԌ4"aeWǪSGk$sG5iOOG.!z<~Жh:klЃisc1#AiS ,6 jO:%iH㢴7٪[$aSiG)|E */>|^Pauͷy6VK]rk5=4{7wbYJZ(Jj*VxqY+c7lD-~.?X=h~R8J+IJ=h d=ռrܑl'JV>RZTӥn]F/% | 7##9r(cx`ApC9kSJZZwо/JVbRE,3nj7|4E5>\paQST|'!lŨ*} G6T- t\,I)qc ԥ=F]8[Vbtzvqb_JO?H5ӟ'FyX=c:U,V_x/tc \ ԐyӻW94`+Ū9X)VٮJǕ +_9(fs+v{[Nv9GIN6?~awّڳ\yn䓏ȍQMUz=͟_x]OeJImOg\|Yxj\Spb6nX34 `A5O#GMD)oM+r]r?of{7{_~ 0LM7^{AմG^k[L>Yl&L/JNӰTv$d# ?(wu:TtDY0:r_#{彽'}Ѽ{y538ө Yr%lh2cev78VӬVj"Ϝ̯ݸ2]?"ڍ+ lۜb:a yզ̠Xbe+mʢxcD񿧑C۹.:hfWV;Nw!g;Rk.;8{=vZk7)'R=Ӱ~\X]m s3O^to=clLX_[V!~ҋ(&V~9g=cNjLi }k*9kz ZqV!͑_`vJj*VAy~> iOfP|%JeatYtaѬ? PxU䕃__Iw|QoS|} s8ۣg>s~4/9}js!ܯ3ﯓٺt3~OGWv˿W~9o[6]?zCs0PCKa:J8Wr;RU A&8wwVflo} [n1awl*V_9e ֞fߩTcҤgI$1lTS6bRʕfc'j7q?&1s<#+ [ni$3‘iNJO<6bezC|絉4kFTN4,VLy34gbp[-+F U]hk=WѶWmbJ (FbA>ڗ+Ŋ)c+@Ȣ+H e/BHKkO5?g;Lj¿@sd]ҋۇ~T3+ P ;'N^ox{f<~2Zջ{rhyWP>]x ?\IevRז:9?rrn);lTobl=ʛ숽\yU{&T&ee$=#D ]cyҋo\? |t2k{_=X/9/v·ě>E hOtM=`eWEF5tQ)E4%|S]~3=,]s~EaN창[('%~/Clt}mw"Ҹ5i3O9niK1[巼ËB;xŁ[|t[\1ǰZ,K 2z Q.6WKr0AWIk֪lnc?7N85{ծxD7Ѳk1ʄ9BU~+ӪŦi rϖqX)VvyX)V}ARQAjYQfWPD̍b߁Yr2qiI;KUJޗEDJ.뵒3;뿙Ϯ?ۿZ*|j84}?S+_qc7 T \Rt\l9|=X)Vg;}#_̰a+&u` ;I]zY5] O? !D+acq$Y&TU(ďx'[ yD+^߾<04}^(Έf"qjB&栎36"iRoSoK*LEѬ0jWEcW64{NaRbJ 83gPL*͒3?I'E:r 邏mi+riQQlUr.z;Ys>V tyqf+6k:8sqc^܊|E3lFcc\j6WSn}\O<Wb%sPǕL:#>}VxbX{PC+6|'yKx8~B NYKl6|]J-"Qnai8%˷ T^=aj.d -OB} K`w,a/a'Mpt1xJ"14"+^/N{!+=2E4e Wi'&F}@qъ(%TaWr:ӷ4Yǐ*LdQY};zGa+@)`W s=!({U+p;u괭gnfoY jAXCJH4#`7=Ԛ6"+'rQZqXK;+qO*7u\qX)V(=Wfr ~4Ϟ :t\`@ Eס|}`\G&|?txa頥OU+h8dd$`nV ';A ;b8*QK(TМ%"ԯ<\_IxpPS uiق)=,ldIvUx % j[)HTflQKuYF}(C)FI$+_n.,cs yG8,bu?dBd?/C<=+Nb+Xmd.xb`%M3 4@-Vfnyʼn!K`e[ VEb^+zjJZ+')VFvfĨX3e\aKq%X+ ȗ!bX18#5h1<ְ[Z5 ;5‰tז'ٹ,VCY3&テ:8Y{0oލzүg5W߽{"a z,ΞחUw>8ݶa_W6oˋc[]fAO+Ɔn4ŗK8AMYQi/^}ѝwƹ; 1:x"VS::]Çl_Q`%W)QT+Yl6e-M޼' ׍2[ $iPO.,첮 k/V/>|ժUq:Ȟd^kQ^n%&N XboseBDufⲵ}` D"?WjǕ`Uu`ewu>fu{`) h㑈E0U+W2Gdo:sMr[AJA߃*3 8B U+g(B17!u__rOg]}p篞*Bj@%x?HO>Ʃ:;Ŝ:Si򮓾pؽg[mn?l?KVo?" oʲnl}+x'z˨\1ay骻饎kmT3gkn3@b-Kz|w~~sȵ|XիwVOv;Oi-?kLu.YB'$gGus~ _'?.YwP}y^#ݞUwwq*)5{Ygo*B9qiɉb~ ò/fa4tk˙j踒9|L VZWbEߛK+Z**VjSbUc5Ij5+V0VTVPbUc5M+t 9mZq-tlL8NrN7rs"Ca=IH)Op(MɺJ3!!1ucR3݆ªtUlE0mlRh$BAd%bʌ6X/b.]Q9Ƚacd o$4Yr ӍL[0²( "')8<5VR,X:: OERܙaiO\LBhBl&'ǪC`E {"g2 !aOZZU]VJqUWx^5rgbX:yZ+;2bX]EF;sp…79uԚTp8 ~hGDca4i$܇fPsFktx|bIqB'ұ sEUQ13l:. I%e0s.$J +􊙸$`1gҊUp)VAƀ>ٮAx|5;Qe)mjOܲޤ8?\#c-HC!H0RJLI>A]F!{BXr(>?Qg†Ira'n*䐐}ƝՏm(ea[?(I /'DnrbT1z|!W5 n*p|C刡G+VgUfIZ|1E),B>g JCK9Ø ` QHR!A6 G\3|< !{/BB$L 5Fi-lr'PJ,9!!ݪij$pyb#N21Ʃk]UC2CU>u2yv =hPA+ djG\5X6i3 sվKYB4V3Syr%J¬:Ɍ1QjaugirK9}Z 50"c@1aN&1KUĩGj4&!0P1<0NVNJ hGJԺ)?d'_8iUŪM9+l #Ae䁠2!3ץ(=H(TE(1VLcDh`E@zs-3;ν ,|;;o63xwW!)"\=}OϠ^qFk jC2A\%ˇy( 5BzP!TJi`fqBߜ#AjzXw:ò"bR* 惢B؟Io L(ʺ"x<ŒvJդ6 ΁N$^9 {]ս5Jy%ϠWw =(2 "_-7YWl:'w/mfAu o؃x*{`;DQqJm^rYތr҆m惫ݨ$\p"PݪaȡrhhyizfZ~Elr 䰬K"'3  9vɡ`ed^3c!kEfA+EEn絸B9TXWeott$-{ԖUjdYXޓp& MAQҴ 톳u j!ct/M I4ʒ$` qR7Z gf}MаPn4`=kMeIiR+kج&j &C)1K(!Ra%X V2<Ƒ `J"_,Av@Xؗ6((wһQp!]Cg-1i Ϳ.KH 6DIe$I^HCFCNc3˔(` S9P 6 >H@6*e8\%ϪGё)T(m FW|qB^(X* ]#܇CI=߽"g{6ji8V`%Ϡ^.Ay#x)6J+E+W7`5J6΃ R@*tjLC~6. Ķw$pJ=+S-taf|EYdi!q`LrKNՔuW{U ƱpP"65(2#"1w+W6ԓ{X3(땟 dm_S  V3vJdCjpN Vc,r2(xYr8Do}F3I!W]rhB8HaA՝CVUXQ][N@R TnR҅~aiP rHs," ZDƑCb8$KL0RaE9tliYp*J(X VLW zŃ kDfHL+EEn_g}!nyYӐd3qɋJE6gFw=U%C q˙uBmBk ]$:ͰWU*NZ UUUuTaV(b؋I^V}]@^Ƴ&O 9V]8[٢>酻v8%X VLW B+YLp{y )!2 Dfp2Nu}ZU nڃ7Z!9Ug|i֛cpP\SUTEa9, ?lzH<9g٦q0AaG&s]IIaK lKiQbCQCȡ.+rQfPrhV2Y dm ӀMd4>"ZLJW"iО^@Dj.rQړqW]eajktXGuMMPf8YyuPgX bPZŖ04C4h8z%%eOM(Q4iGsM8c(X V zS kƅDd~/̰06gqD>f*Ŀ|Q*P& aFQ (*Uyq D8(T!epڑC5t8}C& 18uIԇAA ( 9l#!X}ƤɈt uˉ$%9dsow.9,QƦ6d5c_VSteJW z.{Pd0t"3|tYTv*P__b^xbK1"Ґi:hf=ՖV͚YUndt6/JS0Ez],UظSpDȠ;,:"DN)k 2LCu}'c2'qYtAf)h]U=K,kbތzt?e^9l烬WY=g @!DES[ʡqWP\Иk؞NlrJEFVDrcW5!D)Fl UHn Humڐ<# 'Q6Ll*</\i ՛KhkVu=QMwP$Ute۶b¡$X7?`4>V^.A8PAdBht^aqC%FXPi[͜Sα3+#a֥n&ojrC߀1nrHRI'҈NI ^y3Y CZalw=\mʑФFZlj b p:蹸aߏ'bDR1~-(DF&jjȪ,4FTVZ4^CyM`X'v:IҨwqU LJc@[52{.Aluhbwm3:= V]2tA+p]ރ"3Ad- 2^f ':JWa:r=(ViBBMWwUN*ʆ93](tw ([B@aJĴ&!j@h/iUu>~+"aEJ?B)d#f z1ac> M5̓0bU㝈bMƬl*v1kJMH[8T$i֒&Y3iA.uV ^(r9Ξp1U+Yw˼g,^,B4j7:Ao mQ@H:)7jbgmё_A@A@A`WCvW:sGq*(qk=$h] 9ĭUa9uB@jLWA@A@Vk)D1ЍآDCѥ+b=4lp!ƗPU]hBKBӳs̄饘5Y A@A@A@إHnLݍ7= ^iyQhbgi<J& 8Id$@iC RU]貱`d7'vܬ   7/)K0I t΋dߺ'}F?$ jS߯A@A@A@?Aڗ2tF H3nc|CgINhMh͚DAS&qidA@A@A@X (r\Wh S=ڛc|e}CIdlO. ꉴo<(4+aA@A@A@@q:KW*H`a4 / U1n:>g"M4B5)}Sk'geA@A@A@Xn#REªqhHzѴKpen؞<$k;Q50L2&,lDR   "Аa3AX9Mr (&uU&gQHAA@A@A`"OؚV--j0ܚ68(rJߝA@A@A@'֙~gX*M84f[ҡz>Nq?m:H S?۩A@A@A@uNL2$] $>V&cEwaFm uU ͕Fk+w}.Mb;)    ,7^¸0&/M,RA몘`UZWu%DN+CyGwwߙ G" dGzyz;dH4Wo_2OVvl/~yF3*NmyS wHy!.=zZzk·]>c7T1fmf Rf⸩ȝ!,û=ڸVOy-\x'!ew/Iw+'+wӍ]MJ%'+wӍ]M k .[go/.vl/R."@IDAT6tO-Tm#坊@Uф  A?ďDM1'& D>MRa!~f2k06 7~BۉEOWUp{]]s~~s{=e'IɎt5(d5Uw=<׃q+;_'qc_w{ wHyg#գUOM$-?wvWFu ( C]4ʲ˸gWpU;Z[p=.'Kjo&dK}'Kjo&dso>_g>|_'oyԭZF;*{n-,l bfik1X S\Zڹ R J#(I/G!D&^cw.=d?N';Uӹ̓Uܩ7"d»;GЃw^ON硷zCRm#,N$uI߂U6Lb!r1>H3 0Yꂜ6 ֶOF6݅m+@yHʄr7k~! d1w<ى஢e?N';Uo<͝ .zBWƭ@F6I*Hpq>*c|=Tqjv˼GތQe`~Y[߉LlG@扇B dpG@扇B dpGu^vs}/ys|K)48=Λo]T4ԏ`X&bzC ֮Є2 )91;x^7J_3]{7Ov! dMw<>eθ#';}{λuWʙ ґC `Kavc|0LYNiTP"ɪ:8Uɫ(ێC`z^FXvɃq =dο'; 2OV_q߃̓j۽I6/^={'_Ian)<5Λ*$S!Q1> {F6FTe2a +zP^ 7 kr'u_y{^pc:kW%2OVuߔ̓Pɪy!&YKغq@^Fc|0ؼq#I,p˼Hm MaQ}kqڞ-fRvw{{K&iǺUywrS; '; ݏ̓Qw'dG!yrw /0˲8q_mew`˖-(Ig(<|?H`0(t^A@A@A`"J7Q &F&M%q7uTfxbD9t7LbDNːA@A@A@@[HM9 &}! ~a] 뺡u&GbZI|MA@A@@{><1lU}C j1>8z05JU]RMaÀBke%#u$dA@A@A@Xn$A\u%h!$]bms] uiTlj jM&M]}U    IFH) Ui-l"cw60mU=p-S<յZ6FȦUK ݡA@A@A@'UJ'I-EE8[RM*lUeEV8jƗrHʲ    ,[0(uc'I~07v6Vhϴ$m/q(5.deوᲝ20A@A@A@xbJuތMФ烆 #MA[WÂ$ 9YGDee   rF(nx0AWmw BxEIZ) :Ob$:r^A@A@A`"~ljKQ(y+D8e1^!5Dqf>\NA@ \wu_.A@V3^yM6v͚VS8Mk{a 6&dilR),Km}hWܾo~w~[\OO~~r6g<6n5꟟n/Weywc=tϟ]p _Ї?{{frv#Н0Q_?ax2 oKw~e333xֳ{:CZ/ П`y֏ 0k׮QGcp x03sڏ~-듟kz[u[JyY!06{Ͳ;א͢ʊY]u/*|0&I"簨Uxm 9U:(FK*i9557QZ_qgꪧ=+l3]aMúK+R/8~^/|RZm:Fk yx ڋ |2@@)W+q+𦷾u_+vx{gu^Gyի-of|׽=RY@ ]+LpZ5g; hba!vf&1E5XUaƒᲨ,g?tUeԈLs}UubضuϽr6lk?OS␷׿W_uGO_qp Z5\sΉwS4y+d??tW|q9/+֬Y'؅\~?y;=τ7Uo}vͷ,>f <ӫl|&IG<򑾽V: {IR7y׾ px9ڼ/ͯ/}iƍ-yQx{usj_UU$?WGu“41iT/os>ɭ0۴V,2OߦUR 0L VҗgfxJV9+^=L4{- +d3"Н a `@%}k&v{?=/=f,H;w]9wJlk{m4"D!\nT~vK:b1'*AQ2 t2j.B7Ȕ|AI=~e3!?l>PcOd|Sr۴V,4OPئJy9 }orr6`"nuxЃ8?wCWYnWBR? Cl7;^p11*t _/~}wO}]z)c?A'Ҟ]ƂM~WX|Xc%j7ȡ77)H{8͹~4[Qa/F4UUlx=ӣok ;~wy<$|_^hz#S:{饗b!}(DK^">/Ώ<[۵~L0oB=|_}#ߕW^suAIx>lټ'?8ֿ._ݡ`zʀ^x=Y7SN~|Z{4~w=-~}٭)P׊ޡv|3:_ G"8ȏzh}v^q{o.0Lxu['5֯<w.BU\hJt[^nO)wp.AԦ'2䤈'[S9=3vMx8;4L4P7aIjkdƨ-3Ћ_#`;pw( 4 /|19#~riR2f] {ZagÝr[mʅz袋_d=˧$LJ? J4g/~ A#0HOOG9φ ߽P+?aP^|E{}9P @Aavq9+ģr>Eq~8*\*o^ˉ-uYq煂#?T>M2 b;|Ka'! w}:G}["YHs ];ҽP7Xmoa8+t@_pǶ M/v ).Xr9l d&i7aڃAK]c4I}1+ 7]&S1cmfvrP7^՞׍ݾ!xi'@M輡GWqKCݭnu+ Xb_ K\ [ތ2'" PXhx'>AJacz.B2\݊aB73ՙg~ceկy .И)b駟N|î0`HN88nu1|S}8Xxs# ?|7Cߏn2x <>< O%s ];ҽP7cRܥScB_%W\/wǺå &ЄBo&,tŵVUaԵ9"kk#tQȣ6\x>& f~vcIsuG}׻9Ch%~m:x;'ycO=ԱJĻy{+D@œ1)/zrr d-(XR}S;.'|>C ]ve"Z5koĘ {Qέd.B'@>?S&r;)h$Nͯ懯h)DJh?7#%?Iy{#`yG|3ݸ fX4E׿n ?Nj8$._)|}Sj۰8vm݉ (E1a<$q|JXZ`[׊}[;Ja 1#ݝBRs6J+9\Û1޾CF;^+a!arՉ c;%[/ UN͋$6u}dpQf|:H^`ML(,U q fB_2֤KK*:5W_Ma$,?> v^ex%LLn nNNd~fʅz|Y|+!KSXS*"D~?#n{[Go1Rc9C]|J@X$+'O>D{s .c ECǟpG>aBaPX߾D|7Wd`;ėnjo@ĸ~ _xK@"T9uS-ƶ M'ڡP ,1Q>s A)Ґ[p􅮝W\Y&6g> )hw5B$2NяgN"BSb[/ݱIy)Da_[aCJ^:ȫ Tt46%I3:$:IR6Rk EJŁhnA[ۈo3a̟a>yO2agJ.Ebn_Ru?)W&b-og (⋌azVT@lcmTx.4^W:|(qκy&V7m8o{Iy2AX}'LMpLI3B[[]oZt6uؽP skWҡYnxatn'qInK\c)%rd .DP:"h0n#ИNUt jMdA8kN<1{~ U133_r}x!>ʡ  0/cp6R OISkq«pˍɊh7S<9CĤȨl"EX +4҄B{؊ XABx?~]~w G/E}A?Dc #TNpFnD ;dJA@A@A@XsfjG}R?P5 tѴQ|)O]GF~    \A UkΰF+JADR+KRMA@A@F#0imMQ C(p\$1VyfP-4CiZ&   ,nXw2#% .RjTUmd"N!+eA@A@A@e@SܨTU]gDtkmx`ҤAҦPKT+Cٰ669;   rF@1hA68"r!6u79: ?L(IL=h\].AO.EwJ   2eilJ(~Q/A_7S'veBE]EQBXfY6a\A@A@A@#n( tԎ4"kιqwi]$6k+:4h*A@A@A`!PUUq# ף\ \J\/D܂@,ge(%M ff|)   ,+ʲtzɢ6(){>h!91&@=a:ٟ{cj^ $J6m$*Ĺ(I   7;5([lN}Xذ5u#V僆,GꙢ* j0rqPZމj$a]yh-]ײA@A@A@yA.E7|8RBZ6.M$. lR5i?7TuD,Bj&@d=uX:LpP3uYiXUby* ڀ7 B(JLwalZ*M$`ld13p4.^ɝ)f$ 7UOQ+Yۼg^UR#鸲MqV) W:oKk#X V 2d]ރ"3|;Qdh,^g~ H0zh+ :\[$;,uCmDqC4\ dL!8L{``*S\f5iN9 FUT^ I}٫C3~^2ΰgR{oK57a#X9d^< 땬nM n& "3 d`D00C , VMTHwUՏI1+cϼCZ ʹ"Ĕ#6iYi7t?m~ȐOn4/t?AM^$z* q`^TUU%(ڍ=o00! z%kqBdD>"3 asqBr&\|vU7uљLJMҩI*U,o8 qnV%4*5構iԞӺ")" KNHR=]x.5u_NcMzӘ&6uIO[7ҵղi ο V]\.,JAY k "3|- 22FpL)9CØ6\Py(9_^: CHJ$r6&0` 0ㄾFS[;F,UqAEb4 $ai 3ӓieY1W93(픪Icx F#'/ ^8Ftrq{Jy%ϠWw =(2 "_,*r}l3"cPmLʐâV8D,vm5=3Cb8(>PW&)rӹ* #*r=έrHew!5V5NMNAM!' 5-9Ru`8GII4n~خR%Xُ2q/{н(Efp8DfpDZdx`8QLkF+8dIgoL;d7%O)`4*IHBLs]2\9c2*6G!pzÓYibSKVGU4q0D|d)i Juց*ZMe#+ 'Z_ !JAYܚ.A!2Ad "3C3).P"ȟ5BfMNyL (tʂ=^樠r,{ZثmQlAmPX% ^RUlMRl@Լ.H箪2SXFA>[6&-,Hch)QRODBy4gP+YAރ`6.2 "_9^dpae F/gˆ " ?ӊLEHRhM49,rH$ҡY7uCҰ>*ޑC[6lf0ɦ۶8$6=3B6G)V#DO'WiL(9! J?immJG+ d^ClTAuF./ xC BÏ/dˆL$H%8V2c"ϠWnYem"3ur"W"GWX&gDH0"x(8C6/96&3HSO9҆m惫ݨlj`DuT@!rhhyiz ?Fl,I&Z-:L'Lq-lAb RXVuPY8tS2Hqvü3)8 ƥJ'&lFkalH j<ֳT:֭$Mj ࡨ+S,MԇH`%XɼgP+Gރ:EfA+Ey+ 7Abpc_Z*K۠(\f6kHܼIqdC*E bd&J,&ICR4pr-Q^D'E[@Ph~^684x9qOÑYSAxJ LF]UU͋Xj4=]с X$`p`/ i k[=(2 "_,*r{#P3RLTl.ȥ4PU'<}8d+#MV$\딄&!(u[BLLA1S2QlR+{>8?yyk!t/2uutD.C!b4sPdCjpN Vc,r(X-)j EE;%Xȡ`8c1@9g`5"6΃ R@tjLC~6."o)1MDO#2UBf\EfƤI-tQM8!QMVwZUa` .rlS]):"2a@xyjJyeC=<^ɰHAE;%Xȡ`8c1@9g`5"++C¨%LFgx0j~%$u/ƀP87r; ԇ?>l5ҝu ٥@l᥶ +z!ҠYD.p#ĘiA0_5e)0ߊ6uIr4,8HQqw++yeA]ރ} "3`d$Deܾ9,ReOCݓϐ[%/*iV*@AvRTU&ƽ&/g&&e Ui|*t4_U8i-TUUQY4aIv. b+v;$ mP1ڥ%-:/8^S`4y%Ϡ[(d 8Н"3|Ad,,#aotnnp^קU՘=!{!{EC^uL@k9FU%.?E]LOh_TVC p(MFEW0ة#9 85M2MZ  ]"eӆ?Ҏ'BuY2'CCy% ςW kl"3`WqY`"Pܾ9N?`pIPRݡIDa:kO]Us taTAGa(MM7I4AYhdY!֩CaY+7 Ci[.JЄ 0KQI>a:4*0DѤ=4e94i_`%X3(OdDZ(2،YXZ3BG@\CAET+۞WQ-P9"DmOkG $`ԑ&YP69$(0Q. hzb&#fql~/@-'2m GqAt=GAK| Y()_ xOXmWeV8V2hJvz%J̫WfgPAy\'lbR4+0椨|0 D6+(0@y*CCIX5V-l g7ԋ.nL$E 39gQ 2Hc>)mOg>a')3*8dZzn];s!by˧Qyھ V2tؾy% -˼y%9vsW w.jyeKMfcLoi,p$Y+N/뮻k֬[?1~?pn!\׸"F-u+:"DN)kt^br'kX(s*> msֳZG˲|nzN51oFpzM-զM[^OMM^V Rͨ(1tE`UTd[r1gϠ`%XeopgP{Pv?%ye / VEe^-ȼYa)uiXX6FP}K_¿_o^{İ+;真'?=4nґC ݵ䶠j'0FCrX6i!唔r!4 !- CmaqKaS)H(J{ٿ<+RAJrlz%Ϡm)k`%X W`3vW;{^Sh0þbl;뺪$K݄w[Q{(МnO9kGŞ{q lW_ukݬQֱ~D!h|ġ!8C"iz|V-d\ 4`:mtMy>n0a(MbF(z8"IJxU7&QrQE ]ocXn xPX'J6S1l"ќZXz&PQX->?`}hmWaV??o|k_tu'ӿ1 L =͛6 vco;86iigwG?,f.6_nþ&&GU =dCrؒdri _fY9==ُzɥoټԌ>6[mb^J]_\t. 2ݰ!zo1:/o3'w!:CWcҺ"`]rJ'QXqUw UbDhuUDI$ۖzVh:L2zmU9=H'LMNկNLLӿB;Gz?ᩯzkabS趟[NP&zgu^",:+Xqo~h=* _ʗ]Ê'{ߙgAeDuq[ova5)B4V[399㏿-o&5n{2pÆ Ǡ3mݮ/?3sԙO}3]cפVQ>9Gyww k&.?qz6 Vl8eæjiI60|2OWskW^}?9^_g׬:>X90Ox{[ǵ\|_xǛ-xN Oy>Ȑ۳-"(tv =4J* :U viw XE|NuYLٔQQ.ǙmQ.(_%b y5QCSop^4GEAM-Wa_tmg!Eރ`t`^Jd3C& V8ףЋ⪮bȃSX?~IbMDȡ[6mH!2PQ406EAԪ*eBh|Cy MuZ7 xUW]v}+Ѵjno}7yƙ3['"h>={M\v;[be~ҋ/&ɐ^ EZj4>'n}? Q÷1,?WN9轎g+:ᮟ/'ʍi;CN| 7nx9.G{.O=!Xڰ~x5 2~Q!ԃZQ@O{b8 a5Yc|p?-c$ US*VXpB7+7* 78 D 6‚W>SEUwZhmgpAJb{pJdDuo%DA %aq`Ȅ !ZՅ.xH-qibPZf-7)VRP6er8>X(94 IaG9S6n]o5ĉgmoȡJ3ܰ>߸ܸ޹M40~^64=3 x|8rHcuGpx٥aEe$=6cXA=P^}5=V7Y=]UunF♋w.VMu䰋30K{]$5CS6n+ I!VġYyE'Il)Jƞ+WK_+9 k"2EfqW@![%JRUE#C(EcHmP-V*,FXF < bj6Z,j\C u^^[ډ #624W^{x_#TjPUh[8C,lhΦ&&o 굴 3hbߧ7ύp kvVSRCvr=ŊugufՆ rtY"JV.m=bێcEe֤)r)X~~Xa^/qXj[4ajy%X VgpJvJbھ A& fnc谺W"X9&Vn7 %@TCCr&LFn *МJ0ѪLOS댧;eYDl?Qa|r9M5!tvM;FbfC۱UecTi9:1 #*J* 0On r[9ݨ,s BgsX!2LU1d}sԔ!qQ;Xy8w/K>G?uc`bE%:ߪ2du.Vy!2Lm'aEF ?"9Q[XQM.+.V3*>V`#XzD2 V X6 AY|%X VAn;Jny; IC" ;`[Bb { 6o~`~f0K]&-SX&sQyCh!2d8!6NQE6 ӧ Ch84n!)72 L'3Q9rKc cpXeCùXq-yv8Y~g;=S?u{ }ݟt9Xim2b <MyMK6RQB"XEm!N9<$+lӼr}3 +`` kgPbjɼ"k< `%X9 AߏW"3Xh$ V2q>4|@F^yIY6({#{BfX[%Y  p֕1>\ U-=IE{؛L& HC0w4ka4de250)[ 5,5OhDMBK0#ErDV8+p틲{_7)O}q~!;IŹXEQ "ZXyLw*4p͋UecŽ1TD 7Mʙ&RUM5ۼJ V!ʯW `%X=8|%2W"D%]0$25>l6F%#vhȤѳq ܺuwdf<ԳLmVM8G/6">xwrdSϚ(sgmT#]%qPѰZ;0iGJg a k%l8ACݖVJl F<(bȏzF2aO[S>V3a%Xs/wUW_7 ;ȣvnV`;dʡTk2?NJ~ŪV5b^DtQD-bnT+C`.V:yuf .x;3(X1]e^-emg`%X{pqJdD]r{˦: AP ?j1yhݹR6 p;7G%^aLdNCfԝ]]z8F)6P=Bpv=$󀗤a.%g$JP$ J,`xg1=(k$ D$(%.e 3߿;wK{zﯷ9uׅS0aU-#ػMMZB)l(+= X%FU0q*UZ%W7U%X-\D[{;z4Vr.:0d!}ҰZi*l!5.["s1d!VgrHBTsa^w8'V9bWEy{2gR>[X%-2~+H&/a"oܯT_d"bn{PvTfP2 .3,ndZ"hsVKmUV$vnHЧ_|$ԃI&L 4>+%[k7adrW^%vuqP3h"j2p[q$Z-;} G5M΁q\6XbLDhs lC,Å͸V!-76K +hVR}VɍndblЉqbZTLGњ>SzUXf( YHy[m%soE"j9wlv_81մ&NzkNdu^}3b5{)Jrd pѓKTa$PaE R |lXIa,>"doD9%tMamsBcܸ_-ѤTRWv~t =X)VL K7W *J$vCcgJk,.90+L:I{GDuQϓԓOw9 GoN:cb"夓~ab,rʁ[^C3j츕V3[/o0\sR˜s0gΜ:zЪ9%nOZF T(˕nr4#8e?:뮻:,x䡇(;/YGpwG}i3~-ܒX(Bwp,VwI3ό3f5=zߞn͏wz-箻Ətq nѪT!ZHۍHyFTeJX9Es7VAش/<5 ik&QYYFb>JWXb[Q.+[?.Jg 0J~e"X)VگdDܮX)VLϢun{|2CZUJeQꐩcEt) 9"L/IXY0+E,]SP֖/xšzgߜ9sr.{" 3OX rkn2y2j|~mU<+Ad_8_F8QHwB`bJ-x܃4kQ*>Wpʩz-g~^mw?bJa|(ڇ k03֭Oᇧ%Ʋk? +)>O*4rLD{ KĐ/n@=?—P1!K`7MWCfնZV1'[ bJA!Wq W~UbX_I'HW4+ {Pv=XI2W Vu$ʢr&f/^̍ BMtqt-N Aa XYSgFb:>#g0>lp\Es>l15J-D)4MXvu;/XP.F`+%Q%#": +.ޚc.:ׯhbگ|sbX'v:{|U[+v]Ihz_a+_j%r !GmYTFn-hD9,;Ps uK5Πath*ݕbK5q Y` 1f>x1 a++؀v)sJ !vj!*] (O|EblԈ+l3feD 4K#DJ%WJl&+*ݩHk(t]B:{K̠U"©|PYԀr8Uϧ˕TL&cE/EvFTN1;M3Y!*":Q7iL^HJڣfaWhkзNOsg15M)*et:tٕo)YBT,&$t |: 3|"H|U]PnWFEI=jc"OF;B%574qegIÓd~X`v`3*/W uÊW=ꗩ2QrR0iLR]AiTKȑ ##0.VK+a$Ћ2 )@lum!䐯__QJdt |s;MeAe'WYB22qd5^$KH#a֠|#1Q#/d!*xU7w: wlv +AGBE/: :%b-AL8Iٸ+bº@絸K8JMΪ,ќǦXY`*bUگt 2.tҹ]߃mk$)VU~h P}|bgY '0~lqJKޫLC0D31w ]~w&d𱚾PGo26:i'}K~Uc@ c)G  NMɄ%:h, ;(`qwFM#SZ2bE7~cP+ۙ 6} ^2 Tf2Cv…A&&ʜi&!|C_qhbj!,TN=Ւ SKRhC{iĂt"/s!Njz`UjC]4/ "H> ˹|أ:j4F72$9N3R?7#E ʠKIؓ7OVr%O6܄P@hr΋7BhH02"ߑ ..VhU*6/o4QNj8Bs)i0G5|1UEًvOW@C~eA < H߃*31I *3` CMf0!j"FCc-$I"yVV)1fӒ0A-g#5bkjVšILtЖv]b[KS`l8 tHz)|XAM|ՠX)VگEß:0SjKLŪ!, 34T0SjKLŪ!, 34`ʡVpcA8߻AqN\d=mlIP KrNDt}9;Őh 0ks/50o ״cbO,T=ʹve.3T.BCܳ2+U8cJJxCyq8 d9bѸ"@XrHcȡWd&`Bt\t6s4942!iUq/8)V+Ƃl:u=2Tf `_*3,[2CvEQ+jC IXTcS./.|ȥnOcYg"i>'NK=s+]֖t8b{,Z!oj=? *J):IhfУ#_KP :Ťa jNn8 q-Q/sU ͪbwW:{'f K*_WKv/KJYKŕ( zVɡ1D l`x9'o D[._]G49<9%!Q.HZ& bl{H=ϯxĞ#zQh!BtmTQ 9ȯ0D(.Qz=G?Q{f5UcP\@;+Q_@vyؽUf3̠ʢvV$r2qL$De/;6%l2m).ZX(,A#;r[$9 P$z$AIqػVM8?KQ諞NXYw[xѡaMZFqŬH{ J~%#Atng { P2 *32PrAyTĸD;۞!94īAHqIޅ!MA$ 9aSXQk͹=0‚5$'/=iw^ ǥ̵d"f!|)_1^oKb˵Xl>T{lVXb td *3|dL*/UU1Km0[ ìR6$>Vj%*`!k_HA+-i*}z5E3c9EbX+q-RT8}!jNfHh9egXuI6r-6^MS-BX%8TbJJveNPAezW2TU2q$y!DFof=S\C0ѪIw}Q^๎ak!5ϊW.&Eb1[S0bgqsаL!CVD. m cq%$1F%w)_/e6f|'p`Ūb%Hhctn{*3|e; TUary0ĥ'>`zĒFy/l?BJ9ĕbHx '!,3'z1Ib>J|r> ly.:ԏ+xYdmPz(\=QYXRՍ=j;):e%/0h9JK\ʀX::{PeTf  *3O^bb&Z[(ljPQ&"QQQ?BCg&^dk f&F8EuhS~# fu`~˥kf${GjX*&)VM;XbCU M~*VM;XbCU M~*VM;Xgo.&4?|qҤ.yAtJ`әR'b.d25i+!Kb:bNݥb4%`e =To:N>]{Y=JV( T,nL+gTN>r+JA;W|sUf`BPNRJeQVZ2$3,?*;%5$pgY-$t2ݕ1'b0߭*Qf]JFwIQ +˹b{^ݡLG|M#{[lC?ODǞfǕr6 zY6ʢL7F2+Vگt &WfBй]߃d&4 2"TfPAedb2CՇ&~D#p3D ]L̲\vG/M`v޼EBV̆OO&Ycϋ!  pX*;E7DH)o&<\$kܕlG^6vWB&0Ot{]^U%Fm*s q%6Gj뽏A6qu1*ch83ô- lŊs]^tSO!ݯ̙=zs=RƗ+ 18{΂a#F|ӹ}=htbٚTnyf+͒HW"|+V]~eԨQPApautТSIp7h6(e Y&aaC#W~JT&BIJx!6FLy6# S1+!ge#0͒C2ɊAzc,;Ma%V%6ÆYUs`r$ 0c25qy.$;jI7q\*>`81_zQŪa%pV?+` tnj@2W|r/2)\ mD{kk+Q*qp AnklV&-3sBcR376, uY:`6Ct8䴮ᐦќAE~5D>'+\G^PPSMtezlhX1r* 0ɦP[|[ $ +&v$٢M#eSބ8Vϖ+c5WW9W!VTe'l:_ܮA : *_}(]NeY`z C( SNRJ]b拟1qd.ܖ2*W0f05@dOB!2!9+62N,Nb&{ԏ@eĊS@FUb9P؄W:{sH̠ʢ*C0yT}8(ӪRfm!˘w &jQgŒK|1NB|4uK5%6C7IpE-*Aj\DTm_ *?6WcfQ'(|Z{fKT^SE-C%2Jer2}pXX5W;e+oEo1X}XIG5bdBv}6t2|VJeQۓ8İp Z8,e.7/3o ymx[KN.mP  nhjYʵ)a U ?D9I)308D LDvJ KFh9q*"%X$ְX=zQ{-D&a9B  UVʌ>IxJ+bگB߃TfT9Yf ώ sa0TN["T4roQl$7fQ!-eĔ?2Q.B<,8tRE$<2(CYjjImD|< yЉ e@p7’gey%&WD`7WJV6 J'<9X}[WMI;M*(VKWX6ŪcPRd${L:{PF -*_UvVTAAp 0_B 'ZT6PmDӀq:UQ'eDK\SIyibD8NmE)uBD (+btbH&HfB|=Qt<气e4uLH1 oM/Es.] 46,8V,Tگ|S=W!VtTdZԹ=WTa%en_F frl Iq "CdjZrn(^0 d)`-##( A4V%Ƣgd0umm1*ATeDn^V[' TUf3f$gX-?'!x^ /$:QXbބ֢i m9/XRD5y)$XvQNTگACLhiMv{pi2W|r/<]ǠAzˀ8Nפ"wI 0|PY~(:]BNcOBJ+P,<$\(.P Rĭe((&Fh1L{) j:FsK\#+uu,], 1AD N$V*2Ttf-TYFb[2l)*VECAhй]BeTf,*CFV8˷,rrQb>KN~>QJZ XWɤݍP`&VqXQQJJ> &}aVZSp1t59"O.đcڀˇ &X^b"GyHa6VxbBi ;œ,$6gn>a(WtQG $ B,g?XTm"9}mQ&*AT"fzR3rosb+98"Vhc5hm $`.fR&13f!իFBN9hfTWQ2d1StUR0 QO| UI9 ql3PDJ= H0egPrxnA̖+'??3w} ~_}_3ߚ a%¾a_w߽W3f~e +4&#Gb>$=_w3o&}ҹ]ǠW JǠbY\nH|䡇H,U7¬ٳ~g?wO.rr@2PxF8Tm^@VI5lqjIIey}dUW^},KTӧM^5 pn;oa#9CGsWϜ9s 7}Q 믻g}M79cڜ6XW#si1335IELn;f!fYƺT4%/EG ȲFIw Dy+SgM=+3J.>|l%4O(fN6gV{b[kgXsB31 V2UwGǟGxmςwϽO>8sfm6roin'-\ƚkpghmzs\~o^(n;=4~0XIWJ=Z7/]{ɓ^yU8@ G; .7ޠkj61Ǥy7p3cg6;QM6C{ ,]v_-˟ګ5CW^yw[ӧ?κ;SO=}wn{wo%Zj +s~~~EcpS [_~'J+[]w#>zԩ{1<jց:b0atJunW4+ TBˢoLz}oq TzM&laGfb ,spZ̽i3 8kh\>U}Ob 9$bo5Z8LP~m_yG6\>3_x9͙3tvtT|0ƈThE_6|8'.:<=c`)_p-xН]\{菱[,yǭL7aL5j;oߥ?ҵRm+,2߃rH =!=7+ +YT^I㐽sN|_?qm^{'M:y wxq SYtՔi{#|z _MҀeNܞnU rx1Bf U" ԋiZHf:ґX%wÆ ͟mc#l͑<>C?|5րL<]B? khvǾNW9ƤI&'#[n9ed_Lޘf'esXt{]qlмS0flyeaزfFXB+Tϣq96C$L+j|>XH$*Mhz85lW0%w4-YBYIa!VsfF%7V:?s/p{Q~N:/~ۍ7 V_rV[s ޖte/t!n "TL3v>O3+2_8>j'az^"cO3j+;rY3엿V[3y}fL)~ ճ]_ɠ[ fxr 7:hݝ(µކn#?"cP8Κ5 _UW[mX 5 ;_Qe>c1Ih„eO`zxG 30S/;X"E8e@IDATZ4o1PFJw_UVYueTA2qàpZHfO3aXէᵚ&P6P L1[I};,P>ѱ+;_Xmv|Y$\+>jܸqYw]ЙHWFb;̙BΝáW_}X(IKi7>K)m%j-(=q-=4Ȟh{j6*eK"~`-r 86KX79 ׍P$ P2 -,7ik V"ƁɷH~ϛyC+ i(ٽPNdϘ9n9>vDi)*2fwqsqa'??Dl1h~x/ӧZz뭇W_7y~7jO{)ss1cgfW~i3fr0eQFȷI&Qgx.Ar3&c'ST6x;o<$N-ӣ>G~|~6_3АJ%WaD1|56$\k2W]}u^|S⋙$ϛ!4UVYObc߹]>H1ZZ^rcwm7ܞW+|bX̠L{g46Zh}%aD{0#[l s; _v^Y*<,y}L}H/U>.CY*=_jŞ[XbClXGnؓtd"LN躕idnSx/vesSO<1rH!$͞;; 7hdc4\}Y&PR`8D$Wr]8&t,6-ZM:Ԁ_qZ ݢ HT36ZOZ6a$yfظ|58 9Z[aHռ;BbYᓳ3apc*qsJʾwo4w~;f[l{1cO!-?N̏dʆ9`UH>,ٿ_%ZZCK+OŽAA~pB -Xx {={ߓ1h_yDoE_WY[h:+r5 Hm0AHv/ 4[%F6_|V1pb\Z%ޓO{E;>v~Հރ{ ~XzOE9wWWSfy%=|[i>JFE,*ur +UXh U2deQP2a}c{q1a##aI*M!Z8Ib𠣎WgDyɧv{#9?񏥰68)g,9'aڤ*_Ip)1Eh3`Q, 21L6z94(GR,rH)!RޯXlRPҚ)V|X_3 6N 6xbXÚA.XKDV6/sH64 /[X?~5֘1c>W—EwμwLq:|/ `%=^!;m4gUJ-|UyI;YWx p2y]>ʔ)}kֹpQVׯ\N<f=c5Ǐin?"siW i`;˴ɜW?W_sN8;.Ap 2W18wB®@7tSgƤIe]3ɌDke{:ĭrgg,W rZuz)z582Ir믯tBN4skvzД7RܮU߃b%+TfTzeQ&v34|%=#V6_EXbnYߐOicF@¼4+y|oL/mYdQqVJư>?_=&֘'tH, ҂*jg[ZuJG{ĸZl)5` {ͷQOXrHyN8@9(xWQ (XX7]57B_-I^PvZzc4y7ee?(T4$fe*W rR+45 X8(Xd'S]j`] U^)bF BNg}fHǡ5b#8sWW7W_E72ȹBjÍ7fS_b?99_)+qnV\ɾ~!mKJʪ?BmL >믹qqDRqLpQ!cP},:v_̍\ twcP![!9Gs7K>QcFZp!Ltsϙvgz.q?/~^a >-6w }Sp f JA>mbAݱx0 }w?h^mcWNӯ^S} ?y@D)Qg,Zfkm)]ҵ¼i:_`_}*V5A=h(KX'/Jž6pV[ aEDk '\kckhב><8j|#`rؑG _shʢJȢ+n qVD9n mhG#TDp,> C?\lλ _xO(I?>4,}!Exܼs׿R"z{'tҨc <MN{%a [+8lHˉ,Vӵȗu2 bceWeŘKأz/4擈BCi-x+1ZI"vԴB,bN; My^zemSOiq+듾6ޘ()2 `veXD*C+i <Rpcl;"f[/L;lÞ{G?b=!q'H A f!tv rR7O" ؁?u1ŮW/w\k[vWӎRZG~ ?Ͼr(tO@%Ǝ]iyi1c?Q>1^jYo|zA|j}Awq s9BC7l ^LNM$ɦ䗾u7<Ӟ{wvsij`6*V|2CeQŊas|&Xpq^+顇{7s]]s .d|mr;T"}q'_m{ deT(d`oîJRB H?CL=&.)EBM, }9ŋ- cDץ "%GYWא+>99;|('qYZd\+ od+[ 8[sSrrà!Qr}aѱxСRޭWuXϦpp:Wz_GZ՟~ď/#McH~QQM?W} +Bg=o޼mQDƠ4cE=:_)VbWƴ.2:sbV*bZ"o}ʬ9nnܘUhC`fˍ w.^eI2n=P̷& ( 2 B ;kjO0 "?&V*\^t_9|ʰhql%g#g˙k67b~`YwfM e 9ڙ;QqÕK*!d֑CB7E7b>ѳĊs?HБ`M-dVvC)U-(`ֻ%6X(Hd!y]c!#ƍo`b S9J$&]ؗK6ӟ~/z؛T--m-#6YcPLl'Kؗ sO;_b(>ex|ۉ;n)o%Ǡ*A=X>(kz/+W+Xb}r{+Ki6#rax~5PVj_ЎLNۗqz ,=`8NvdQ0}#:r7B /qbT)M`to|Zx rɐzT². 䐄.,ݥBkƎAt#; *B&Al9K|B$H=5TV\K%hi~(5Q'b%V_ϴnmyŊ{;n,lYW;a^٭cQ{2_u.|_Bѝ4Z+z د]{Pe;&*_h\^eyysŚ@c+2V8rU"|1[kqZG(Qb9{l鑨.0o#`o''&WlU^qYCИ L@:aghO9έ(m#g\@;b*+A}~eOs9J~cPzӟ$ *_5D]eȢLs +:8N>ZPyBϙ~^ǂ+ZXpH/2MFUȚj1(1M6Y<\-9jJfB m7/"L#T: ,XCƪOJogLگL)<5i{'lNW!Ʋb`nY+W: Vjޯx+Qղ>dܮs{]d{nWqYۗQYT^I:u 6ǩ/%Z !}_L.WZJENxO mL%V%s pm >_a6'!6rUZ&\D? ^O8<# H.Γ֜2 U4/=jJQ2=.X0nX:/UfPJxБE-S|ni4(I:}t 8=-#fD;ldڋ/X2r*!( vL>’]pN+.7/jzs /c)f*aB,^.N,,A:_άs{J߃*3tʢۗ]#Y8=*"rs5rSF11 sŢC;ӵI飊U IUC飊Ut'ǩNuU*=5&z@F4?-&awCP|~+{K h9 Ę4* (s`HnfLU}|HvWLNqI4Lr }U5Ռ՛J54ٌS\4ݚcŪHV0^"zݦcPJAaEuun1AT,cPǠvbc^0bu1a8J[^B|ӆNKu-Ŝ'Zyu(ϥr I%pB&_BØ l) 81g|F'FIFhrHJ7JVs%Ԃ[8+s;6 ?1e  B)mPyZ_AnsfSAp`||UzoeQL?vW8Vvz(ǩ/^VAT.UZ"J{=M("("(" 5XT^#D㸒Zígd|@њer.0G8@B_c5V 1crߣV{ģ_Nчl=/m"(1JZ{ukt* o! .8!rCGr? ʥgV'ia% JNNl;iZPE@PE@PE`!ݍONbE@haC>ԵV)aTx{bWHRpQx!qE@PE@PE@PPii糥LT6vl!' $h|'~F|ЁB%0vHƝ$ Do2N͕- E@PE@PE@P@1N";8%=k F˙+]-KCrprHȇ0\"F)"("(" YGLg}FQ9$}Nƾɹq^3g #/&&Ħe-maE@PE@PE@P$~{)MÂX&\qa J4L PB 4_ 愄qȺ="("("(DŃ^3M&蘟ij 9aCZ>h[)[Z̹|7|b!E@PE@PE`!J3qAbD,x]) $lǭ[+0 h(/83b|'rwGcuSE@PE@P!p#F@dCm~75j[s n^|yƩu>fihj{-7l-?A/LCѢcҰӔ}A[PaWMX!rflB.׽"("(" `>ö6c (^+HÆN3P{o葙q&fѐ;_ f;׶|Aa\jnjFA;iQǩ~`"|W K!4ŀu"("("#ى0C7AX,}@?9ZHw>73r{ }%N/K/ sQ6b" Ơ2+Πi*8Mӡm&E@PE@PECG5҆&Ԉz @H1)MԐ{aKra哅N$X5'K)K+Llr Ad"FO l_PE {믽x!GgGǐj6FP>SN]a߬~Pz뭷O|_kP{4-Lt˗ڝgTb/be(Kͧ EDkF˗8>g!K)Mc5Kqqc,m ҥ8OW\zveZ/[,nu5ګz׊>?&dE~ۣ-k^~%G8@mO_SL=퓟OG#L2߯ʉ/@6#\yUzu/Î;~|z"mXa?31sǭR?O@;w޹檫&FKk+h}'r_><"6>6lح7tU*g~"דW9s;XuzW=K_ꪫ>Ϝmg%I-i~t X*.$mhȺWl ROS_Զ/Xl%_S͹sy9+0x-Zt%z/Ƿ~T*uwwoGcǎp隷:7h~lF[o-X:i1cdz̍6Nxi;cO|3Ԫ>'HZ2ĬTL1>&i,CsRdN&bHJRNPG4}iV LՈfIjeA5g0IMځCe]yv>l߸ .pg8㺫={?ӧM{!,?oOpu[`O+ӧOh~uvu}|7Owl4O8c9;3Gy䕗^>f /^Џ'Q{P۟'oz(odAszߕpe LA>&5}ܳƚkWJ& P6 ռ%͏9>S}Gl~I;d[>sV;zi 4h䰄y^gA`?&}#:j-Blʟq~+V5 8P'z)?2S/d:V GJ&BGhCm[WN. w 3f`."|=4QVќ~..p!?!mo8M;n\]Wl~Q.Zȣʧzm׏\ܜ9s.:S?bرh{mҗ;.} ?_v_,~:W A5V[ǣnXj OgNz5/"L62V3j5ǏGx5s_yef ?P>t32$g_>\~[oɧc@{gۇ ;ӕ,i0{^Iqg?!t+&{$sL7]"_ltt #gɓHznpiӦ]|:u]r|WpL|#;X$ [n;@;-9|vSBoǎ@ 7ptM,~@m nのꪇydV<_n,|pa7lOyAI/&Vw;^I}[(G`>?ӟ7)}%fc3$LV'4J_ chx!&r]3݉yk ۚVΛg_OZk~xn6t%N}5uEۋ.ږF@oަ}dM!>B̥65 3ӗއ$|לxy=Pa;W(&}{EBh~ܑa&FDĆdi~ Ke@6}S: 3o^kv}WMמA> lǻؠ3j?\ {ʗ~4!Jb B z]~7,?s["_Kl>U̚~ RgՎ;%KK:\$! dKACɊ31 JdlqLPf4AO@KZk}|GwAaS]p%~)r ,Lrw4k~tܹHEcgy&7_sk8á>'*dz0o^a_'^̋NA[`{`oW_EnIo$ $ /6.>"+7]|]h]tQZbzO~Xn?~kH4-@$ܰ[K&ce&'{ QG^M<Õ`ήj_1=z˞& A9qtB{s *h] D>| oO#*\׿b BnOzuկk,Y/ݰ= o]fҶt {'?Eӆ^IՕk W'w߽Mp6|vot6Ƭ­I 2 [b}/LLv_Y_=8f;;lbm!dz꫑OW f俆G{OFlkr] 8Op>ږ&Pu_ 1}G5r}Ѿ-߆fƌ 'w2Yo'1z7'fJ \Zä˫ayYi>{ϿvTE{ºe8Y[dVlvc9#_fٙr)"S)IuG[Ց ?JϺo'uBP ȣǐCdk-*tB䐋!5gO8O_}6Y$_8 S<7҈|gE@ɧI&z,|2u*E[? )Ss\eUH|a4A%Mwܱy}6PKaJK'FiO9fB!W{̡G49#a iC>H=" un ϗ{֊U2mil{A n0vk&WTp6_h]UM~6t#S'Of2:,YKSN0 :~o${t6 9d,?3o'jG: ^&m^].a_Xq7&M:  ylf{o7) {}{'.[v5p>?( m=_΄>HFY,b= }ƶ|]/4ٟ}Uu-麣}%䰮o7/Y _+|⴯ +#/P{7Rcu 6|MKUs2YIo;UY XL҉&JfSz GML&Ryi/͗x@!>HP; 4o|'E}-[$ާ;J_ }]SD4>8HӍ!{DO^O|c8[~`%EP}u6H ]<7n^];i۟gl6;k~y_uaUVfx@o>3\=oW:?==}_){=<9گywzaw>f+fc}:2w׿{~yo'_?;m-k?Ǝ1l$W3v{kqcffϽhדΌnvKڲ˿W_rߗ^+OuAѫ?&];^?ӯy钧mԆTO(}NĨ s|hQ6bF갆1rb6 W &|Oz맟|g2N8ed2H=|VM<\S2!"><S$/Yp,|V^ډ'oL4?Ϟ{ER+E{QQ^y=J ,zh- 46D`*Kz59ȅdkNW|DEۋ/F+ovgJ}ȉ7G4I7H\?FҸq&ChK M:o.%瞋F7r 8bS6;ҡmH|auBF_Xy+Biy}=LAhf7Ӿ:6'Zd:e;ndC1o8?nD_qL:2t_囷*]cһo7/OFtK LB󀓢]v% *AIfsy0(DQT(uOi,*.*zW%s&?걝oi:;%Z]&9/VTRtl$`$WI}[ԿvʙW=d}F<_~֡iRvvJv?{ ״H%氼>Թw)S}ʭ׏uZmmE6;Kn;/>/.~zԤNkHt"֥Klo~Eol=sAtbme\>q% z%QaN"DdK>9%+%Un<'--u-nI iH"7/5^j;#jY2L1ԗVXq<3씓tiU^aYEC@<2ޑ)^I4}wߍ`صkW]fɨKH `x=Ol࣏sRL?e&Z&j^2n߈/4DYVl۾SRP'*po(5#3LDcWNxFxb1M^R\]z/sf:RKN  !d'R04׸^SNh҇=xJx?ƕI4_l'?u,51w*X"/P"vUIEgN`}2c1A(56E)h _23Q8MB9rgSȄϝ3ݽtNUN[\&N9<>i|ĺ q yK$=)ՎH=X$dwwyjYT:CuW:X4>c4ypSh,JdWƿ0G|R/ljYcy:z)„vlVHwl>Uv,TojbB4dj(YTti]fL,1;7Wp֬YeeLT4K[pX Ia9l7cq(% YTg֏Oa/ rb{<"7ipNvUEE,cN!;ÇC3Lh~~^#xoBc~[KDKdyY^L,dE)u3ņW^zDڦ}DfŠZyy0:"ub3=fyB,|/헒Ҳ,ƴ 'n:WZ3[L8}fzˮm1s!\iQ7}=}-L$]|7K,"SUuW)<]-?'ҒfnІؤ7fęڛפLzR%sz3/J/cppꐗ^¦IJHɿ2Ll|C*/DE* ciK"Sd;ƈsU c:s7iӌ[L9V,VqWĿyۼJo/qoz6SJ=UAӸT}oͥ~E*#YFu_#™c,53)%Η.4zLw)]4x#/V֨QmVQfz(:b=-<@uU/A3Ȟxo7ﷱ `8UCRfyPN#cNcãn^x]L79=L$٫Æ.DyI}n 7x.}pnx45L%u챟LW㬂4"7~_8d4ݻw/nߟ pdf҇>d)0KF <^:o L 7y@IDAT:'?8?9;Ӥ]O x[34w+ʹ_|i/^g$5|'ބY?JO@۪MjŸ~wܑ!xPUW_MƾJ_]r[Çs>/jvѶh0Q8!>'b%نu{Hz;wsLXѣo<`]hBOCe\ IQYROn9`d*Ɲ~LMk ggb;DD/>M#ve=>9LֲYULYv-2Qoil<-ΩPSCH%o&*J[rRd ~ӽf+v˫nlciE3f[7x~6qrk[58[VbYiq:[)!5uEQN? ^t VmgU$D^]lx mXMޝ ,Td΂ jQ>94${& `^I=T^x']mMlDa y^m؛ G#p7vƔHc̰̓=|jkk֌9k{} bM2fJ "+&dq5gI`b́ {U9\nV)G L{A,Y߳X֘#իចe*7F^^U}߈~W Y6|1 UTQEy 4w!;G%sRZ޿E=Ӑ$aҰLFэ )!qg\VfG(Tf3:tPEE%"tHjnw+^ EMRRԇ"R%~R[RԐWʂ6Oǣ"9)ŦT735*مT/UiwekvTˇ׉i *? BuaP _,V˓vq⡑Oh(i2qFA] UoGEVؕqigďjoX-,~  ?w,~'ja&_SnEU6?{bdʲ,NATxs)SK17%%De6P>e\ATgb`Tsخ*SU۴e!`!`!`!`!`!`!`!`!`!7 OKq{C}:a* b"BwW-upéua۝q`2uF10u:'fy aLQ^R=@.Ok^+n];C #o/78;kzC"b&:by4p9wڨ-9OQ۪~ FNGcT)ɺ[XXXXXXXXX5 甔 $bT"^UZvm]p;nPEd9ltdC_ٹ4qZ>`ˠ B hݩBH/t;2tG ;o؛@,SNee% 1ߛjyLo`+$xfwkphnBڀ=q Cn- @`<ԅ8L8 h<؜Zr XXXXXXXXX] p]kO)F8"ls$i0KCQ<2vVRQBC~|YC# K2n!`!`!`!`!`!`!`!`!`!`!`!"8ͨ!FQ3X&CIm؛bN* sqU1yƥ> Jo,,,,,,,,,,,,*-"T':Ѡ0Օ I`h8 lH@k6J8⬀މ@u&ϣItE:ӂGղ_j("OEC1O4rX7            -ͯE"ѰR4p\5N9ɳ(_.q"ZHH.Hbpᘲ B/ Fz Rg8O$yPٗFXFCQ>-qD#ЋpT rmKDLaKƮΡ'`S8,ʌ, 4@g:K1ɉ$۸qb,,,,,,,,,,,,66lHp{P2VQgQ9Ȏ>ÈӖP:\h8dsR62hG#Q U cNW H3:mq`?ݾxR@0!EUEE*WÑ0/vs-׭jXTR]J"ʋ+~8H r|rHDN`qpѨ=qbckc*GN.Nz4V5+-h< Q:ZpHyp,JZ!!?]j3d kGʣrBv+YX픮 vA ?+^j+-~X5gW\Ԛc2rWUPEHoP0}ՄFGRD 'QA'5z?$0'2TD۝'_ ժb_?IժVNbaN-cd,)_&G=^Cx/C!n[Y׭r JT ;M&GV`Ɗ6Zt)OůG$`vBbT9þۭ95ּ}ߓq۷m1T7d!+؄!+&80Dvb)lI(;t"Q^Q-LJDZcѠᩪBt\!7ީN NGU/;!&;>N$B HB&/UFdĮ>Rmu9Wgf&}Xt#Zog2gW!ּ=y 소lf qr3d0l@t81g ].l5#%'2 0@DJXPwQ!1l4}Q T{NE8g3n/+ƒ-I9}TŨo<2t- I<@jEbjB0 *"Hnȧn] >H-~ev lҍ;5g Qy5Y,.!)P ;Rp;%2sWbVJJTOC";2 Bh"@ C;/DDUl08%FAI9.ãM(GB/UKɭ&cT¸UshcUz߮"CFEyqu]#-acE<]Ǣ+?iToX=X튐q09 Xs#`sQ/V %ͯ$r7L&eӠ3o Cf3Js;;*HnC+D$C"N7±0:ʈ]>o&T{j/!mlc2@E0 RH/~tb45X0⭕SmW!`87݉ +1hP[0%[Xo,Y}p/TW [Қ3Xs|X+xqsQ^kۭy{>(Ĺ` Jْ e(CMȖ~ = "\0pCCȔB[ ˇGS^*1 P`(qQ<GxFՉAD;YRơN%BN;|?1`B#`ɭTrR@~R/֥l%, ,VCRXI탚)*Ub϶ѳmoAskq"8(+ E-ʷm+hP'uގt66eE75 q$98M@,b,CH vpTׅaKmb.garWOрP1orm#pɼߐxN7JUE9S-Ip02 y?XcGӀ(//'77. `(UUUʰ/۰H] 񸒓_JWB6VA]`P-~ev>x`aUQk2ݶA+Έ۾}{:u|>h`fpEEŦ%xƅx򧵑 ܜz({Y˞wd/ڸNcjSG-BTkBW9<q.I|3&[S{jFDW%YOC2a7CQ m )Rcsᘲ/HTYcNWVXe_LqB2è@}v(6u$^-=G¦TN{$ӋpV,XXՐm aU4QZ8H/ ̯qК_>sQ4jղC:Y,a]vF ÅCxmo:\쁜ݩo#eT{2Ѹ[ ҕqB0SL)]m{Pr F 0 9Qr]#:bX$kO >ˊ_MB#ȑ`$ njVFS()_%&Iy5חG*G© Ч;z%^MM3{8SOy7,jHW6V>Xn 6e+ۭqP5gWY&sQzJUEiMeuτU|X1g {Ok]T5+!, $KI#f0j3`qIJuüq{Ui7(")rpr;CJh!. CiLiU \ )- "aaeF/BW񒕳=նXtg FB~^\Y^a X}x SK#~UQ^^C޾gۭqP ]1ΰR=ݦ e _Ehu). CsKv2qwu4$W~2ÕxUIlD6U!ZPdO'f͉L'=l{H0{"oƮݺbK¹Gqac+b?qQGr}ĉUJ*^W F&7cOŠԀaCL>NNqtpnLFEڤVlD FjP*RUpJbȽt뒛˂U0$k"S?\V^ZnIT Zv퓏?{螗SVN@.3V<:7SX픮yiSiޢtՋ>ozBW~ot|o='~ʕ+[hQ+/ Vz*6kL> ]Qf+g|2 ,tB^~^vںu}ʩ}0PU5⭷Ǝ3}W*AHY2A*C]wwxI}0-]m ֭[tEߧɮbBx<3A3V=X(S8YyÑ|eW߲y[kȯ>˖Λ5kZhį`yu5X+!^}u=w _t *.>U xN7't Z֭۳cήҿ s;^`Kt"4q/(Yzu6mɓ ^d =r#r&>2u ݻdd7~Naa]6V:#'N\/ /(^}p_+IeI E!OohyAU$#t/k8g{JIX&,5^Aw; uEēȯ~\T I;o۶^zuώ@IIIݺuT't8mKawL{gf8I]q.|rNqƽpDys.bAÏ`4SKAr(GHZ $2rWn[52kXabٰx=XMeº৴* ni;ކyrI/_{ᇅ]J[jZV1V2M{ǰ!]˯x͛6E_\Q}WS\pMfׅ_mƷra =pɖ-̏-g}E{!j%X}Wͥsf‹.jҤ}/__q^>wӾf򷙼sg_4/&oZ3UOfΘQnFe<'ޘ1T㯗_NQiJBW,<(ZOi曂ht_CW#͛7^z!Ø`M!vgW3O/,(ph{RפF#\ʶ"_}5uM@P`*oߡNHW+$?ҕ&=KWX1qΞؕ`ٱq0ƌij_׮M'^:`bRLZV،11[=ſd&>"6H~yuz<~%V9["'[EA+Uݚ3(خsO3b~i؆6//Wj8\N3Dt5h]dz~:gn?υ}ihn^;L!^,~d3vk@C*8t3aS&\4Q֟c.* q X"`v aL3C+l*2+bdm ly 3 1On#;o 15q_CRD(Z崩c@| 2NŔą}4a 0PH ]mX%\mlߡ}8#UV y 7m‹/f Fܸ u؃y$0zԨsDzGz5b"B\,՗m/*{]sbV6oZCycQobu=s/ ~6Dƒ-[nW\1w֯[ױs/7ޘ7oj~_Nna[tŕWZj5 _uUaaʪrJq Z҉}tm=7hв%K Ο}F:xyYgsKM19{$}?dC@ac3+!1#G ڤ? (.k(ggҸao N?8Dw`0UK. Bg,r^&t%)qǞvx9G-S'%Kwr~OxZN\?.5!HV `YE ߵт/=cf~M!` t}{qCdN= <4y<9.\ VTAf3*BȷϛZ۷o#+֐ Z]լyshvAaEYUN>}iҥ }o'xקɤIxnԸ}oC=UFrJ:+|^/tUY駋9#}/7>P;#̟OJWƯ0}OUjd7tFjWWGYcC'q.{-p .fNE2~^];W1ѷ׫_"tWSWXq_&n^P%^+E(\uNWw1"K WX>f׿n/pۗOI7S1D"UAjQI^|u.e+ Q ѵogsz~_u˩S@_y#JdV8Oo}Wv= @,暩SN ioRqԈ&bfPa6"\Z4}W~2Y3y$1! ``%}0͜!!/[U<~X!Tz˷U>\MocdR72(\pEǽ뮯_LdR5ʜ{^N]\|q* a;EwWI+lgځy s>`W|O+7s%tE8(s gtgjWp)v5]2+\mBRRbcNoМ1WxgTi>?Z{kjB1*Eƴ/ 6F2/\42M\&PΏ+#O6_ޘ.WnR߂dޞ%ypBQqõY+>jgJ\JD8$L$$!5c:׆/+-0zw̘_f!)-uY6m:mO=+/ٽ"!K !W^{]eVh&OիEyɨO?LJ{m, JogeT>S8UuةՐ{uԙ4oͪU ~yW^~)lnX0~r A(|p O= 3fh:* V ?֬N<AȞGiw yaQWq|ʩdAE,{|\qiQ#;㏯@㘣IGa^,.e]!_m)A3gkۺڵXP8S'0X¬ۄ/',~mT=uqNhLX?2c.j">ؾmOҸIcj{XUdΛo}V):wkӝ+4iձcF_rE 3UZ]f Z{ BW_rUzbNZt)+!?s=Xc}ק/_{_M;,]ӿ?oDܰa:^g]h~a xJ+aIt%̯t%s$=t%Lkp;%?_4=hhnbOuWH߾m+0`w|;N3[Az̆ԥt>PCp-:〚c}>(,uqQWSUQưa3# Ry#i#l fѓ: B_QO?{a5} :u,y¯B*X`5m#Fxfs饍 jRsX :YR+0IT☴m{7bOizWn;H7ackUg۴ms^(C `-^,ּE FЕ|#D]w)a ~y+*Pr%hM*UzTȑG’Q(a/1`W_=ZP`7ū ˌW\yU(LQ9 7߅H5:E MĻ?z_yV~-Zɉu Æ~7}1˜a ]W\uw))`! Qz"P PUYIa;X~}uԉTHJvE녮 }JJ7J,hfP^V''߮0nܨc42WO8,\@*3OO7f l5mߚ5J2'~ g͜Kd./S.+zjN_W'2zG_5orѢE?/\xp{ePPQQɦn ӄ_M6GJC=Pף:+-+QWbB!,.Sui-3zTgN/K4x9Ñu=`8eL;m2HWkV_Z1KaP(--gǠT~}?cƫC(w2GyJUn*Sye2M+q 㠞3ڗ$v{~%os=oC iڻ^QL+5S:.Ke% {znT"J`{`eq2#;|U8{vԹyd Tg6o-;}L ilk:]tͶNj 0CE6_Uvg[N uuC 2ݳt%1G8Tuqu ̋퉫%Y :l$)N'sՊ-#,Dk_s],N?zDۮZr矟y9nX|/g>ˮވ}NX<úu֛6nCǎ$^xW/NtX:`A3V$8 |0}Ph>3;vp8lV.oaPߙPNwAQl/':A(fׇ 4:|t/E`UM۶GjՂZئqFj*dz˃.J*]be+%+v+>V4&`嫥Vuz-_1]P-@T醖, oyZ[yx.s~7nHf͚sAr-ĂɅ͂,sG$_2O Vvcꆲ e#X}߿Ovi a_H=¯+Q p Kj Ah`5R)܉5">b%|ɣ?”a-R@oAR+̻@,)mޜ|ůr [oWRB?0jLֳdXd>*%cRqu(&YpOFyjI'ޣY[d1)1tϫ7/||Y4VBWyBW%o?쳏>|VzG>1s`H'K^QuB/NtQZ:6k&c(  3,]{z CX03q>}b_pQn1AMRiV*мys*@dbDWǞzH9g%n^t{svKe+4,,z&`mxBWڶk[Pz+$]fMrrȇ4>/1*M+qF2)_~+l c_|8o:t@)]ūe&SjuGtЧ4dH`iI!`tpluЦ-h~.|@:r7'\UҜ0?nkDc0oAeJLV-u1PyP`RtJmVm۶]X~Ѻc͓ 7v[\Tꬼ= V3Κix;Oʗ02 U9ؚ[V]xU?pVS;3mvNc#㺙c{juV&ܓy+?pӰV7& )Ci+clQECH({H!Cz*EJa䐪0=<^ì҈=.uRe $1| 4@8:p5hBgU"oזwѮ]L,*&1n;21;Ԓlق[8<Ξ|ܹzh~n+ J4I̎ K5zuT4rܵ糘> Z4g!4eE-$eVb> \xlŀ@*1X$h,Wù 1BDYDxuJ@b u5zS.CںZP?3Vʦ!Yrl|mkƍdհQ#fޯ+7_"H¡$jNW(t LW(WOU328`ըQc]Y??H6j_ ,v<`Ţ:3rw,Wm FUpA4yIc%}Q#GwW_}s2I`ƊlSXJH镒K-Z$ՀK j:!΄IAW@OZ|YVRtBlc@l߾= 25++It?mٲe+//**:W/誸q&"u۶F63^yѮҕ+3VfJWf2J(h+P j32K;B߸8fVw|$@zX Z WXhѪKYx! S}܃RWESDʤPY; Vp'a)p7hWIhl!j^ )^[0&kҒܕK1(3NF`[l[.j+^8Ƚb8XPX@7_qcN@IDAT+Ν+ U;?e2DX*d2 pH$Z+3]yWItŪP6chº| .D# *8[X[w`{*6#Guj>XPXkS3/^'ٚի(Pe tOF:3oZW0iΐDWj Y0?_( ]ɐȑ8%p~eW,Dv{EU(\3OС}A¼&\2QXkԯ󷫮\d%`?hb!IE>& .Ji¯tw>[hǢydޣgO4ˠ Xت$hߡ=Tڶe2,oI !銰u`%v6ogwlGW`>HY UOo//C?]Ԭsg[ɜɏ 9ӭ7RteI*mIbE%EDŽbֈ3|.?eC\=W/ۥG/Z*Ib/l@ֹw)S}ʭ׏uZmYݢb%l>/.~JKe8&Fw]yH\4}ƶ܃;tIF2Oa.pLѸUFY z5Qʀ]"o~^r$oAW;@8Іmv;!LO`~ߣN$^+` Zhrg ¾HTP,EYkXbNzO-qĜٳCEEX=8?_+<$1O j ¶9stC/0㷨u|"R8k clF~2V93AHuˆu;S+1eqX,p zJ@Bɓ.3τ<@zÑPthGd!]OdYc6):^`+O@uN=K<XhlUѬGtWi~g^lNW_=`_mZt1c0vY7M׃b|$oMh„-7K|464yufw>,dv pHF\8OޙˤGWBWU8KcԘ0Nݺ'zZ0,L]xT;-o01PtQo~o]J$JvҲyN[+vf040kd&H$+HIt豇*bqm U3p@D+I/&{Ynf n^)ŗb~8VDW@ӸV}a;8pumχ-8 yUKTKWoXZ4y]1;i~EOŒPWTk\jh҇=xJx?ƕI9 K=KMxH +2Eyjfxs花z fl:Kh,}EU!5a%$tİm`pSӨa(Pz.FȯWcՒoӠQCY5T֡I>Cm߾eADhMW2yE+Wtt$aoخؓO/oI;f]|Dc6ѫ}^7Kv樒+SDp#6aVb)pKc0,QD@ Θ=as )+U6>u ]5i$l )R2a+LHG JC2w.Xqڕ4A#N$=F Ҷ!bɒիyYtrp VM=,^̉ &:I5k4/J X4WuGQp+:,eV(&q4_K.F{ö|]k7_ H88>Vcjxv]MOǷm>oܜ<o yX !lQcX%Ƞ96sP3΂ruf|H'φ"3g~'^:CU4kphGdûtMws?+epghZvmsmc}*f2nPj3 oj YC2ruFJV/SKzF0ZܪoBiemI&ؚuirI/Rb`zIlW+vhO{\'ڊfQ^TtZըU艧5wpͥl>-mޒ&{AaSjNWj( àUI/hBC31ZH,-Qu>݁ucFӧӸa=X~[UcW47nLtޏ?*Ų~ GH6(L8gx=&h`ģٶ]{O$##0@b jR3glc),D9O= *ZU%c6ak$O/ɋ<¶G|5Y6[S7~~׮lhҺǍa ѰIʉѮ]2'XGG֑:LڵWȻ#e:|p$$կW1tb$ 7hVYBWSI7n4fx΄ IJYNreaɟ_/Ƴ, ;բ+@T֭[WM2u'Pd5N2ŪalAVU;f,,fN[l8'|Fy` 4b.X(TL2һ&b17nV}z~ɧiR|Ze*L<3 A$U# ┬A^2|7;S&MFԧa:nnڷer¸qpN=`x|a :It?2%-].Cn+_1TJo]VU\=fSR+biѲz) E]ϳlUrcRf 0 .@o_ÍRaƧ6mr$(ިTn& oؠ 7 zR}WXEsϡҿݨJu7:.vփb3!Cn:#Kifx$Xԓ`Us" .LtŁvD? -7%aU WA@ms[p8r@{C(j tFOgW*8ۢ!{@N$aE_`%EIdycM˅=NJ;b3>*e[-j]9.6 oN>7U)!z-hu$wK._=t?:Kx(w^VYfg S|o7ވ@W^~ 2.6MBJW¯RtR{[pԼti+m@. ])0_T>;>j*F]µ.$~e ǝI!]:wwr+t`c3Tӱ{wU{]yHQd}|&r dp1'gry;TPPbDzg 3ܝbd3==߯fjavY`w{k+uz'54hڗ ٮG$ d~ЁYsE^}ICRxb 3</ ! y@j:pL_VZvႄR;$lΜ'[U{A?mWX  jӮ1fg>`/2I+ԇ|1^pJjUn=>UސrE%3 ͐OyY>{pY#)fܻooʄd8_ޗl^+Zh,fGXڀd\tcL5=`"IHhr,X@@ y*02e?|&My=XrGמbvvmi{}K8p=@c)YÆ͚5߉)i@B|1 J[cO8q%=ِ fl-uu fDY{}sYħ}{z$pA^֘iNtc\%p4zQNyX~%}{.$iȞ{zDڵrǕA@8RR+syby+ZT+֢uY_9\Ա&<|M?̑ IJiR6|%׶^/W>'b޼iQZׇ=>y?7~bS9͠aJӐi7|,*vTk񘪗n1Z#>-;oO| %#bψX9t;.KylcUa?Z[͈ݭ"[t|R?x%߸ʇgC+Xq% c}Zrʝ`~S8Z"?Q[.,-q^"ѝlzWNglpi)6iRcCa~u-70?`0(Ҕ<9j9C%XVd D"%Yyª*Z++EՒ9v7fd){{\_ ڪz4."YEiK)av5$V^׾05m!Eg|sJ5k4h{:>[fuii )mꥭZvȢ*wd h{zV|]hW˗|Y j7b*̋= [up]6uͪU*ھn2]Xe}q|PIEjN% f2VJ<) $s0^%K5Wh{\)rIWYc`)GlW|y5h2A"'P5eHhC+ xck:kͰ+8 ѽp,neU?rdsRj}@"e2W(9uƪCH84`͚ʖ-۸U2~^lm:5fe Q}=јؑ]f}UbmK$^%q5Z~JՆ*0d´~+/+]} eϻx恧޴A|Kxz_9G蟄r]ޛtŕ>!}]+, b>{Epȩcrto4Wdït͑MbR qfs GNL#OɕN?Z :zlo\cٿ:J}L8cnǙ7o'LTCwF_;y惏Q=;{ 7Ln7/?ZU~}oWGt}Nre{zVyAmgcA"تYAt88]'vTNh*,ڱwJiGy>XpDQ;*=2"Aq2Ҍ&;%$1DZgQc` s0<}iBw8&nq"Zn%`8 {>~^ܴi; *TTSVRV&*JXblXa" $Gn3ao3JQ_/;Î8{أX<3}nzU첫GTFWrV]T멧0|䈣a٪٧{!C%8x.ozpBuOvPA/U~~.V8k_x۲kƉ^"4uA :u\5CW$=G_jE d-?6e-*$;w.JhE2ddÅ;[\l %L ./"6'}0/sUU*\6Lb aA"{FC1))D6"dղI<4BH3|0D$t_*=r\ GrOa{ jk,}e 4S [dytOe}]tqƕ$u]3Ѝ '*!?gg?G e,1f<rDP%G#$uqFE,̬ޙ{ʅeѴ 6V%Ee {Sa9Rx0%#gbB->[dOd@vƙ`de@];uK*vB /V %8ɔ?ẝ3lLTUSs]tM7ܮ}{RW^.++)=C~Yӧׯ:Phݶt;㬳ytk.kMӪQ+=t+H.dkQ^W ].L^u0iIdR˟lC p6] K"\Qf@P̡nO(a"T̡D>aH6 N9LqѰ GEV2RUGي0ÄHsťiVYSJ9Tbx ^lhqCK+549H{ +u9sZhE.2C,)KQ7ͯ&sdDz~]]Ԉ_i=@JTP?Bg#I/=ST﾿=#M5mCB4mhh.qFEZTE͒^ѯ8Bx% ~~!ГSW yCaKT?SiѴZy +Vޣ -[A=5uRc?w'f `̨-s14ѣ2g{oM5kעd4#h xҒk<3ʞX);h"Т8LhBQ0ie1ˏ41O;iY9K:\Jd] %b%t؜ bjU4ŹX UPdKg2aX,(ͱ*~+捕#w=/9>vCI?iS~uw}=g_㎿=-[@"; CPy-[:h!Pbϟ?W&/[N 1T#\aM;"'^zsWUU=>|ĉs99ݪǣ"g…CT,^,L.\{bc9?ϬcD\X.vct'{..%EX~hFaE1,o댳τN4Q+ {Y 0>(|_&=ȣ%^ھc[oyժUP&nzԘ1_x7_ z%i+4XlQݾZ{)LQ^K+HG^i.sW+UkQWv=~JRz8 6QBpX(v)$ LY+IɂFc2`N "o悋.~o_0wo D*[|^vvιn?hkֲݫ_6qGUSFA;I NŚ,TR&~f\:VI %U=S!olT($>ƢGDX*_䮮P4RxMWУCS`Skz%Դ6fph$ Y_i09 E5V+/{qpX0~Gɲl[Б " $[FT: @Vx()X ѢT_d{e֮_'> ]v:` phQ}+VfduKr8\.r J>eF4q_j?S eB P@]i 0dk#V``0>8()͇8YJ"5*1| jSH.7rTkWd:5VWU`Tm0؅砤pk21R?/kbfV[OHC3gT1]x!%mGx*oWO=7"7H#||駣^80ЫKţG7czin^9*Z]j4V 8Hr@5)D*J$%\V`PD*~-:gMᙖ-*bv8rxC eoҟUsjta!;fG~bH%j*v&k2N{EZ\P.ؙtqHSI !?W3, n^cQ=`\@FV*TJSRk׭kӺ$ YSCJ3{D-ZB ) 3qJ0 g܊FhW\ft#'L q#KK5U abZ`&-<@IDATj X?" b%"m3mXXsm7V?{קUӚ6曃}u7Q駟z59HVCZjE֮]뷭hY&bJ=Ξիcp^Uz_=x#_?ꛯ>gJwy+I^m,f}UkQm۴*Rɟ^Izuq -3f,!W %Ie7sH_ZY@`1f\-*9U8&s`9OߦmhЏ.4]4 X{\1=% CA^388Uf8赨ZTPgq+XbyFxa8Q彐ҔlH\`0 _B ^h_0Pt>+1 9pTrz!N|¤(H03 DL#bdAtoS:WƑ M*h*x*G'o+ &/!ʇx +*2j$z=Ǖ7V=TsWv^354A 5\65C1kQXSJ8 8i) JI—Zj3 V%uX `"4la!F8HG,;"- 3NڎwIf\38M\b4m"LڵvhHep0 EqU N+v4 qX"  ځ=/_4ڤ&I5iV() +l*Iڵ+fݮ/vݞnQ)5=UKJ`FSMYX6GB)##0Ho FRIB' ! |'Mauj(i^nn4 3MX:9NѴ1beC4NXd,Zvbvʶm* D栛jz*5m3Rc gH',M+'Iu0k}4^q܃N<BZ.fU K8+C"R`ut֚8ňVfhr9.C[+CF|*h BLCwΙIdpbe Upړ9$>@ n82̈́ iqغY+Ǖ",^i3+AfNZMǑYꗺp݂Hh(ȭXu!;r,MJ $,L;RZvKa0"fī=YSF2B&`ob3~MrQ:̢r#nXIxo00Z}QeZ++WBc4F@#h4F B S(`I(" _L6;h| ff` b-XM$s<<<ޥKFm݌fE% ]/0>F@#l1'iAt+bƧ| %JHap(h@4*N*~_ 70 4UKCG"8WToxPF@#h4F@#4*kk¥lNqŲZk:8H$Z$mam+E8[ZK#h4F@#h!%X\pgUkS5[:*S`%auR mBMF@#h4F@#&'NE` O6CK~P9T 4ÝmYр0x8*Zh4F@#h4 KJA, O끆p9!8B)W iV*xz[9U eo4P֨V쪮Z#h4F@#h B;f8J@@v,~PH +_C;sH jY'^_F@#h4F@#h AR S"N J,#!QJ8 hh4F@#h^e˖(6n&qnu֑9p[7L'FbFfc{q5oOI+ bƲSA_II@$,f"۶?(n:F@#h4F@#9,+yyl˷׈  /||x~ۍ򞬲V:^ZQC! ʕJP(abzF(.M +* c[nF0͡CӰ(ڡ~P:u` )vhL+epYT$=ZћEQ&wF@#h4F`"Cـ S +!l&sFnF[ C2qH "e傖H8aPZA[*[5X. :e8Qo) j)GhI׭[* MHJh$ЍkǃfGmGSnFG?${3qذ7k G\Y fBfu$22I2kB%2l2QEf\vʕ+o:Gi䒵ky=|ŋO0M7vڙg<8_bw_|Faօynsn}ȑޅmkBCw}}lz٘zDnףLEMUL'hѢޥm}Զ:_Ec;/GH$t!EUMH;oMn6|UZ6d[ P%O늶B36S 6%((eY›+UmJP@JsnJ 2eҥXf;|iڵۺ=\$\M%M N~Raa1-/,;:pL8t0ct~P=:X̡iX: 3Cg ^yΞ=Fozmgյ[f }5myX.5UVE3YYrӍ|筷VSs|n|k=m*xfMhlߔa5F… !yȯKUMHЈ:&-܆:I $i]VwFUf ]Q (fj@JB Wy{N0p !\Ⓩ?~G۰z]To*ݶe^\a1NnAxl勶K_wh'Mg7i6ۖ1~NӜa"e T!n~P0{`g`P5l%)!8(s(o9F|$ݷ&_'m#d7f͚?oޙ#rc„o௽nqGu F_qŔ@BY3fԹ'8pР[nG_^׿WXSNy?JʆG)S#|gmBRd7tӕ{Ұ}V~:$[n(~y= ,ޣvSgI/(ēO~/5 (T;M˒=٧2wiߝz*^\`'.]k׮'rJ=U /̐1[onݦ[멻 N㞃3wSgj{:;og]|y^԰d=csg&Y#Grb[{a'ם@sȑ%?4xO=t0NF5͝|sA{>٬a:%q/wuYÓ {t嗻E&oirgdҍ,T?TѰ}䡇p$rđG~WهQIKig-{ w@᎔a7O.,YsK& YWZ~'?|}k.[{\g;Az{>/4&w|&S-<ծ\٥׶`@42}6RQydEH!7?(@Lh%N("r ; X $"qG^_}L;H5ks1?eR!= sWWURh|onˏ? .ئK{oO<2q_oX`VIwAuf~|01n'PFO=$T . /l~=$*K2!'>xQG>SӦA^2[X_x%[˖-3 lƪBH|cּiSvZX^wlouӍ75efsWYL_{ՓN>ݵ݆ {NѾhѢ)hUtxg%;A ~{啬_l ? 9=o*Uo=ݢ&Mbd3nClM4I |s''tl0>|8?!r,bxݣ+WzͬZj!&}YcNw7 ֛o]t%I|',YZH 5gKOhk',Yɛ{5m}S݀d:paFTl+oެ*g:UnI \y*Xeen*d+Og|lx_6ձ][%_ö0 , iYk~C[nzѫEw̡b»O"NYOt>Oe% ,8-Dg峐uE:yHE~LRaxKC~r@ _+CvIzb!nիǢ͟T]N|#u0i'ad]?&G~C3Vy|J4N zm۶%5fqTeLs.>pŊW_yV+bĉ45$f;uѥGr>}ϝ̙ǞpBV '̋Gvm=) zа|sժU3f KD4.HBHu*wG ud|"'Fs02_Z WJsw0tE+w]K9D)(E1ݣeK'̭W_-uR h{'2 լ6 7om.:u4n̘,(?NKOZB.ᬧn ;a*ܨPE@>a9ZXgJ$"kC>XlQFco@$=t##*r'H#3žU)۪꺮@[*P6Sܵd!_dQt2*~ޟ}!ze`@zΌq|qê^wU?oX:~㨗p3Ǎ/}ͷ3Oɂ]q[8OJZ|u:7 鑵~Goтy%=s^kjq_?nҺ.˥G㍆K{{žԯO6jծ>׌3n1F ,a>vy拙gf?}TW8Nw/DjUV ĭ2 C~"!n1|'B?8%yW_y55a0UaKG~:"TO a9حqʢ&ȼEKG D%磧*C8`r ɊQɊ1PA!$sϾeeq1ڷ!(u -ڪd٧˜,l /#w7Sw*opzv-.9s<U!#Sn۵F{elGD$J[A)\Xw8t[v'|aN>G}t* S* )d&mey'=en;ݽ tҔRtCǎY|.+/}V잁|ezY;i!A7Z %^`DЍ \󽝺qդ@** Ujg? \Y~ŪUQet5*a!uШClu~瘋n`[gw$d'_龽ʼn&eC^}U@7ٕ ri\~_ui߷;E"fټ6Cvg{F}W=xYUV^vYwߧ^=ØCy m$d]pt(Q|xO>D87VjW Lmߛt&D/+nDOdgvvy$SpЬn~0BVئ7Y`ǮMe߆rl։9>N1 rI8zt_pX}%q4s S1cء,&/>8'#=qWgxtQqG=F9ۙNa9YI,W* .xJrS4h,a5z(rd]wup^wJw8.\) ̞7O4"LpIQ|kd^oey(82zER&m4 l@%h)d+p^|'Dj0׵|odzYi6qhstyFf5u]wl%UQfΜ"|($lUVB@hXzZ_}Z]w2{Fn5miw Ŵ/V-ny(țz|{Iw|OP= {t}s S}#C(k>na7`qA ~Ze!ܰ%}C~*%nfR2uj~'< =1:iJ S܆)L&ney1)ͥt 0DՖE3aٕ@I77_zwW',?4Tݴ-mN#?2e Ws]vr(PסX°7_=GWvw b[3{ A2TU̖QeͷyXu .|}xĢ#Oө`m{[K6WLfe(;ҥ~Agn8g iؖJ?-.%--'bmo~1n#<wG> =^^t=/mHm_9aN)\2=f`S~y<W\2L;Tl{ :֨/J;$s~^O+Ip1\V2ZHvj4.v8A 9:%u) (vR0?#Rs8=\3f1{LyAC^\y`̽'/wXj ĕH6H2*61AwXpFed<vee%vDG|Ey9z"ս>c:1]V^Nua{QAGa6!SJL,>XtfFP( !opzvȬnt:`> E_|O>aeWg|9::7S$J*U=-H sV,TJQz/+-E'<=櫥鳺,h'  ;gMڟA7Z}%%B?3?3VPkގ'7oVz -wY9<#j? 4ɝN+1 8SO_rz\G9d[Gjt4*!uWhùR c$S^ K*a Q3 CuT.ܷׯL@iI;_h|<'" 7g<튽Qŧ~ܝB\1bmdV/{6۟z.5n-qa^{+d!t2u160+mD$s(#03,~Pl#,)zǢi}\,{j,a9XTC2`O a&^{=N;ڨЪGxWtq <O"ߏ)=YX.od%Ʈ?)9gZ=#@5eT䊞%C5vܸχc4Dz"~Ԙ1s#GSǎ'ULN~B7:Nc|ҳ78=G;n)G2|l#!jӵݗ\ƃg{H6/^>7LcW?/a1)wRFpt%Fiȴ>M/]Icc^m>[jdiիwj4ڋ,\2H崀 lϻLԧǪl?[[͈ݭ"[tS.J9b#?Y딕}FCKcx15DɬURVLKhKMd) #axʎZv(c4愢eҥR~^*"1BL/JEbPCtɕ?{D>Hx3wz2_a=p=z@>C\??eTsιg ֗Lٕmڶ\o,zoj}S=h 0̻1$BZYQh9wg%(rzBUT~Lp&>X|umb^VNTfZ֪1RNsGHcn-XYmO6e7yS£+Q4 Wq[pV10@=GgdzWd$,38풻Lѣ Qv*kAZ$ ,' s賰>4ϻhƁޔՆKxz^>WYr?;em5q>!=0*8xw~&8FCN^Lih?+&]sT'GXLJlx&ͣFLxG{5?f!]9i}Wܒu=blocےw>JO{e$:yqXK 5$CF_Xk eacPܦ_Yn}>:sGv}$O&Upf`߱:oژp2L3gPy(CL`JXhJYS T\*Wܔ|Muŋ:4 bVO(R$w^ZT=z矟8y2`*GS }lN˳/_".(.k< >|j}8 U,a֣6/1cfk+"P7lS!`ll$7䍮l6|$&T84pJ, ȝ$|8WxY8J9%0'E LV6{OTC1))D"dղI<4BH3|HDDHwRfH3z&+6r^K_Y})Sl4uӤ bPq;hTȎC + IK@⎈ӶM⁄DIP{RDG&188sҲ2towe9$G:4~cQv#~nld7ſ&Z8~_TR沂-ߊMy6^G@󃱱My_H* 7b$oƝx.42"JV-L @$/R@sxBEm&ܖP 4F)c'!R,3=kTذ9>?O-z?O=);4 G'l| ہp4JkW; S N,Ćgh×R (ct"?,@=/}yEDOXtF@#ly}cJ *D4u@06l[4 F*-Mp{T:bg#1HڶkC"DL`)~XK9$tC;i#dB†d0j/F@#h4F!g4 daKފC~6)H#ޝL2~,0,~0BxQ [FRa ˌX)#o"GC֦F@#h4VEE;7 [. 6OwLKw\?= x?dR>3 #c0Jp( `WzpfҶfw+F4F@#h4Ƈ">65*n%B~Ә2ḲH41kRmۥ{ G}1l#\i.L?T0kO#H$sHQ!a{W2[@"4F@#h4--[uӪ86Wms$]Ŵ}-W]Hy eQ˳gFA&3J`M`SSjStbX)ʂ]#h4F@#h`}@DdEd`A ꍁNiHl"mr`rՅ|P j j RK|DCŋO0@lS'rKsgzʚ#o?}dŇ Nj/.]t~9ܯjƫgÏ<uJF@#ؒpjjKV jhD|Mv1}ɀ31%*~04Ŕ#p iDb Dd%F@#x"0`y$vk\&ݛemx衺2eJsr?G81^8NQ΢h4Fä q`!ax`ud*n3Sl:e'lQ@C!0޼'{l98hY#GFbҡ(Bx>Fm=٧2wiߝzj4ٺ"c„okoY;ق 8q%]v=SzSabŊI&)<͙.'7 qG:F#h4F@v Ӭ`*Bۥt )"W(à,L4l|YcˆG#10n3K#P` j-+L}o=(x&慗\c?l2'>~5k9L:o6T{t9)9WWUqZ`}%>UZV<wlouӍ7[Zj9pn3ɨCv.PgJٕpaF@#h'X >笥e!!S,~0s3 H4nZ=_d8͟;3<`aFݺu;eɞJ1V\4pΗT^h4F` #sO$Üa `F1X44A!3[vlPEWO0a#"CF@#l /\hoe㬚dۯ_0~핑v!&Z`&6Cg2{}*8R f2^Æݻwջ7SF@#huUh0-<by4U9 Y3IT(-rJ^ݚ?!)F@#Po%Sl!Cw]N&lQ#ITm Xf͚50(.^G ޹ڲdgyuε9Ka2]h"%d-;IDAT # r,$ $  |πx۴>ffDd߈3\{]r*yrGFFFf+"rs;9zE.|ʵ x?7bc>ɧ|?vnO ~ʻ2$AAW=Z?:?S?O~-U)?^$lAw`zX4|)ka;ď<_ۇ#4XTrHübK>B@p xB-"_jWc?NDTrk'E?7H}?ǚe-oJiacx\?᱁B@! @܂Sm;%W\Ўxm0@J<^E;c&LҞqk4K(`#ۏ_F&놀>IeگC k%qE;LBN,`B^DYDE1M/D! B@! ~@ԕ4Mq>'(1Uo%?K0oCkB!%L+tjmB@! B@!e!F˔0$j<"·Г[ԉDN8RǏ8%-S*z/ ! B@! ] K9K 4  > ͜ox!ROtl}Ĝ?ڄB@! BFX J{#cwԆw|r?Y14$;2a B@! _>( 1"yüLw|}()/bSJnz>ؚ)ykcB@! BK@ waHS!Ŧ9r[2rئaM顁1LsNáGD<>8-u(B@! ?wִFx|e݂%WH|Z`<ǙcqD<-M7üEIw===[,B@! ?w#]0T rؙ_,[spiæ6\f]M#9.][K9?hhӵkoB@! B@ y_u%maM?%lDM/$lbܬSh>M ڮBu* ! B@! JI9_)Q_w!Lx8~6]b6k>?_Jc 0_l<W~+Q,5yh+ԡovHڷm*&<_b6v85lJ'laƕIeEw?Ւ$3H,b{ +)a/N+a0vB ˤ CM.DIͮ5p_uR{SWv"s|nMI䱻74_ד %`kyf9Ku8خketǭ^An[{BaUVU^#iگlfqQ=VZ * $@ YTr[D+(38!_^BӍnIZav5ܜIAz-lZ-~7.kǶ]|;1lMaJ.o0mNj62>c˥=ImS0j?޷A-uP SZ* @lW; hCBk$3 Hfd  ħYb3jIh{!2aKZzh4W~,_4zǚV|H] l_k:B n OEuyr ڗm W@VswJXʡzSW+ܝVwr(^Ca 8w Ī!XKLiR ķy#?:~u nΩǦN\o>1svYEl*)31Yr~G%#.4<ި`OסAxST_j^@5JbHWS黹}GXVWuz]A ,(WQ*0PxC20_ gt=3(4lߓ*vD-v-Q@lpj,ѐ⸫5KwnK~}6I$Ց-!w{U,yMvF4i/clNc\b}<5gsJngaqe@sЌD2Z1qjHf|%!YTrqaE FLCSm0-h{]J1Bߑ6sNQ lcגт)ZLQ%h_6!le,V@y]0A-LhGǦ+'+\ T!-!hhv^%S!Jn*ƕ+;(A2;3BdQ[%؛8mmAB{$6Ecwq SC?l&*K)Ҟwk@4v4m:2"cp1um]S 4(sp 31[2  V^i|mk}gqRR8P3Y=V4JXi\栭"Z;D ĻMdQ >ޖмlԨ ṉݣAab؜s1Om.aCAY|^s{.&*.e<+xݺrJDo'%@*bCoJgnuAɸ8QX[#ѭ_*4=?bFꀾp-Ҹb h?'+GBk $_Ie"Hne2)p+9rhv_vʮDŎ#aSߍ'fGr#SL@{[dS=5 ʾMɌfJXـ8i\ipz]A A2+ɢ18i/1\o^ O66Fu;gӂ%Rnܵy8` ݼ #?pE][vz#ݝO-% mj.dcBw)OZ5ΊH)lmzI`L٭[hln7<=VJ㊉9+2$3{ $Jnя~d !*esh1?xmv^Mܞ!s{&Wn;؄{tߛ=alOcfKo)* Jd oKf0GA_0$a!N%4rvܦ:qM0aٵaݜrH'0+U59jP4S!p NFYM,{}i^!m)7b-43 #BރP!#iL-JX1 44^imwP2&.$3H*A s<&dY|%u.kx7@ 琬únFbhюc /K"OD\t~]%}"ͬkg%#Y|R\0RwRnԃ͹*E E)]*/HaIq:]KROVW>G4^img.;(D W Yt oKfhf Qm *-BT g S?DE)io)[.Ե%{-{@.$1tg彳4sZ,kJ (~ r,P#q(@욱hAƮx$/j}kxxaw1K[G*VΈZJsPU]N4t!c0$_dQ_ǹ3n} i-6[u>9=< W<;‡1CnŶJ"|tsB|m6?ڔ/yJQHd?kb3kYԝ(ޏ0ζ/ ;z aJa9X/hO>W$-Jn8Ia[3 #wB|'r\O+0з1$_ A5  S3Ir@9ٷKָހsBe%Ճx'8R03^Ct9O뛖l`jfimX Kh ^ \IK X$F˜au] 97d5]ͪ>^G4Rʾ۟Ga%4 ZZAA e$HfWEsaI`5w2FWZ␍0#]Vszۿ؟.qnܜ315cK󮓤&thYyصmtoN-\q~䥐!JJDBn@X ++AVWZd"Hf0|"dQǁ ޢP(P/!Ͱ])o;OKq5ʠmBɰKYS2d,oq2FDlK?po%LG`gkP>2zC%:Œ.l =4M]ih>TE[؜M8L.D]4(Ukhjڮd ʆdQ~3܎/ybnsW1"\ܚ(p@L<ڕrT؜ ^÷`]/9qIp8 'A ,0L-Η[ +wK!alǗO8a=zOQ1Nr9wE$=d]{ Ld̆CqիSɡ.SNnY<|r=g!!yCFMҦ3=C C>3-GONǡ琼cWS89L\ӊa% gĠuS*R3^cOX!erf ( h\z`Y$3(q{ @!ǃd7!3eו\ Xh6{ 7PˏUI'h0sYʮ tO»SdPDY搻&kqf;C0$Z?o@VXS$Ւh6NGVJJsЍ}֫#W@ JdQ@˗tkyci\JN (јN]+He* yۥh6 W[Qo@"m`=RCT3y40.%kCB!=ΐf%LX}[{$`=npVƕ9Z;X '-Hf`0H,*ăqS "a񹒰jǑFQVJy;DdA/[[4E5πNH!tJB CC_ ˱*!72"ZKtj^6l7?g\K6 F838w{\Wa_4xQodG^UJXi\ijbUd`; dW,E%Bp-żsI'X[~X?u${tZ<*pl[{bV}R!h[Lz#45f6mPfwH3eMkJ͜e=>RS\Ҕ˦Nd{~БkQX +:4B hmI ?*Kft|kA\,YTr69t=r?kzy> @!g!Q|QAuUU$õ$ka[x:o:fgԕq +VӌϺڍVoQ ^s/C&=dq!Uc=%$ZsūD8m** (44^1u a9$_E%ENz6F4x: lS6#p[s*44^ݭjmɧjէyY/^ba)d^ |FX} XAo,=YN6)@@-eC8codwrHE|pƯJ0|/4cXr {'j2;C*bL9 S,I}J(t 9/]B}wrHy̤ bAq9]A_U2"q8Hfd}3F KL2dHx/ !H< Jȷ^FRSxc30>"&~>Z>GO~W6KN8=~mʁpǩV?JFX +Aƀ+Jf`)|7Q2 #է-rDTfvr\cx$b9rRf;C;Vk=RΨ!nJIF9+^īqG{IUDqVJJsPv}뗑 !If(?K, qSZr!xṄǚp=riDXPK ,B L؅N57_l֦ܝl}ιP-T7},63qS/lUG -N)JFoK{+ K۷iu:$K a%4|in++Bk' UQdQe2]Zy/y<p#:i4d#7QJB.&p5yvn6vyID,λg^|WԕpVD!?a;ҧ_b5f4EΊeߓo~l1KyZ,4fg#TſRX +иdhn롾$30& $_ca4iMG :c;l5r )F4$Ĥ%`/Ut5#JĖP8DqTi~bխ,M!Rz~}i{-]H橉!{@u`Jsx[ &:VNʐиzkq]A >#$38 ފ`bup!犹DЪ?iKsH4kjpf:tnzxuKQyg X\U5XQR \S}1IC8:_ڛϯSJT?ǭj_JMN\{aAnEΖR1Dd aJa9Jk{4; Ifv >$_9ޖ`F\cz}JdDbSC"fH! `J605$)ͷ8$=!C cQ'`!ѓ8[!Ӓ7RC  I-37J',JX hV+*bв;K;(A2+6ɢ(ǹ\2'Cjݮjm(#)We| T0ciFv ƬX?4 [ȻK0&#۠<܈͈(Aeze-![x!sfL&ڬXO+pVW6M4^xkmw N>,$_I^G/|!FH0x|DՔu}FQrٝbi]5X!C(ň}\Fe`t)ǴP<6 Grȡ3 z. ďƄ 9gN)r'uM9Cuݰr(ƕςzUڮd $JnF9Tʬ+sA2acK!r1ݬ3=SdPpԳ5904n\qnTlƢf]\6;CdOYl84g7I1BazzzYgMźd]Ye;t"6 PJXi\ijY/|:%3Hf|%Y¾In_csHq [#HN^~Y m#ِJ<~ p8v 'c3ˑ(`k`v#|N)l8qhrqez(W9 #!3x)HxbLGg47QԗaY|Z]z8 $ل^ 4^1 d $Jn/8)]S[\JC?[\)t Y)kW@F&Qޡ6KQP%S"!Si0?e4-^ cbZ,:%vԣW^ݻ;Vwr(^Ca 8w  W;%yPX)au+pN ;@^9,-iXjz!.pE0ikAxʇWzKru劉и=ቸ3fef<<15![rijhկw\SlX8(d69 kGd#yۆ<yriVa%4֥?Daz+{s<+h^Vs<+h^Vs<+h^~CX5j {MgJ4@>SnS:t![#/" !0w~8wACcf 440_OfY 9E1A=H1[T`JX1 44^1;Sp d$WE>z=nSX/cK{ʸRf!ݾlG߱99؟^1]Лo >`Htz7jqֱ=ic=8ch7~Okߘgx쐰B2:b,K -/8[^SJX1 44}zG !!A2A2d_ unpU׷- rr޴;9Sgkge9ƙK-+nmM^^ФM$(q֩:u5 6*pQ:AÈvo{GQRR&uh*vc99=8'/Ca%4^1 ;00 >]il{D>},>U֓R, a3vլIx fWGZ l>䐰<Ü< hNbٓ&nwˉa$̖!.sל0Ly5Z ۩ZY xOzYXҸz-ZPe AcAv_(OOkC?oD!ksq\G9Ѹr!B6Te:d0:9__!Q )$&a3aE]h!j,PĠ1zuvG^POxf™کoOt>2.1Ķ<:$VWeijN7}%33d I,*9w}2[@B5Y}4\hMS\ӀpnvvvDӉYS(%6,2!I'>,ص9h+y$0".ݰDZEsY %q}%7ҽVnDƕ9W!($_@A H=cY3Nu[]wi[jR189īOۂR6k 3raȑDHʉ9rh Lh6r[@ a\ RfrHck*9$TװJXi\ijb1_}$30 $_I5Ar8Fc>/ {-%+!ĉ>&X/>S^OH,n *$}a^Cz%݋ZNcwH~7Uf'!zāliXX + WZ;(A2+ɢ]2<.i} Dѿc'?^ Үp9J9ωo`)7Mx i*H=~4<܇֫:(LǮǫ%a%+ɢu, >Uՙ5$3,?5b٬903Hkp mRpV( X衋FpT 9E+pqbMU8BBd˚F217E4>_chnt~l]ĕ0Ö1#-Plؗ.a(6I!6B49]Ad_%3<}$_EoCBr8ͷ~c; Cqduw.r ~%L3SqѪsg%A"!g~UD"x um{ e3|lݭ^=:6H {= 4.0.0 - fix readme v 0.3.1 ------- - add CSRF tags to forms so it will work with sidekiq >= 3.4.2 - remove tilt dependency v 0.3.0 ------- - suport for Active Job - sidekiq cron web ui needs to be loaded by: require 'sidekiq/cron/web' - add load_from_hash! and load_from_array! which cleanup jobs before adding new ones v 0.1.1 ------- - add Web fontend with enabled/disable job, unqueue now, delete job - add cron poller - enqueu cro jobs - add cron job - save all needed data to redis sidekiq-cron-0.6.3/Dockerfile0000644000004100000410000000144213124502443016131 0ustar www-datawww-dataFROM ruby:2.3.1 MAINTAINER Joao Serra RUN apt-get update && \ apt-get install -y --force-yes \ curl \ git \ wget \ libpq-dev && \ apt-get autoremove -y --force-yes && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* ENV DOCKERIZE_VERSION v0.2.0 RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz RUN gem install bundler ENV APP_HOME /sidekiq-cron RUN mkdir $APP_HOME WORKDIR $APP_HOME ADD Gemfile* $APP_HOME/ ENV BUNDLE_GEMFILE=$APP_HOME/Gemfile \ BUNDLE_JOBS=2 \ BUNDLE_PATH=/bundle RUN bundle install ADD . $APP_HOME sidekiq-cron-0.6.3/sidekiq-cron.gemspec0000644000004100000410000001134113124502443020073 0ustar www-datawww-data# Generated by jeweler # DO NOT EDIT THIS FILE DIRECTLY # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' # -*- encoding: utf-8 -*- # stub: sidekiq-cron 0.6.3 ruby lib Gem::Specification.new do |s| s.name = "sidekiq-cron" s.version = "0.6.3" s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.require_paths = ["lib"] s.authors = ["Ondrej Bartas"] s.date = "2017-06-20" s.description = "Enables to set jobs to be run in specified time (using CRON notation)" s.email = "ondrej@bartas.cz" s.extra_rdoc_files = [ "LICENSE.txt", "README.md" ] s.files = [ ".document", ".travis.yml", "Changes.md", "Dockerfile", "Gemfile", "LICENSE.txt", "README.md", "Rakefile", "VERSION", "config.ru", "docker-compose.yml", "examples/web-cron-ui.png", "lib/sidekiq-cron.rb", "lib/sidekiq/cron.rb", "lib/sidekiq/cron/job.rb", "lib/sidekiq/cron/launcher.rb", "lib/sidekiq/cron/locales/de.yml", "lib/sidekiq/cron/locales/en.yml", "lib/sidekiq/cron/locales/ru.yml", "lib/sidekiq/cron/poller.rb", "lib/sidekiq/cron/support.rb", "lib/sidekiq/cron/views/cron.erb", "lib/sidekiq/cron/views/cron.slim", "lib/sidekiq/cron/web.rb", "lib/sidekiq/cron/web_extension.rb", "sidekiq-cron.gemspec", "test/integration/performance_test.rb", "test/test_helper.rb", "test/unit/job_test.rb", "test/unit/poller_test.rb", "test/unit/web_extension_test.rb" ] s.homepage = "http://github.com/ondrejbartas/sidekiq-cron" s.licenses = ["MIT"] s.rubygems_version = "2.5.1" s.summary = "Sidekiq Cron helps to add repeated scheduled jobs" if s.respond_to? :specification_version then s.specification_version = 4 if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then s.add_runtime_dependency(%q, [">= 4.2.1"]) s.add_runtime_dependency(%q, [">= 3.3.0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 1.5.2"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) s.add_development_dependency(%q, [">= 0"]) else s.add_dependency(%q, [">= 4.2.1"]) s.add_dependency(%q, [">= 3.3.0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 1.5.2"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) end else s.add_dependency(%q, [">= 4.2.1"]) s.add_dependency(%q, [">= 3.3.0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 1.5.2"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 0"]) end end sidekiq-cron-0.6.3/LICENSE.txt0000644000004100000410000000204113124502443015756 0ustar www-datawww-dataCopyright (c) 2013 Ondrej Bartas Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sidekiq-cron-0.6.3/.travis.yml0000644000004100000410000000040013124502443016241 0ustar www-datawww-datalanguage: ruby rvm: - 2.3.1 - 2.2.2 services: - redis-server branches: only: - master notifications: email: recipients: - ondrej@bartas.cz env: travis: 'yes' matrix: allow_failures: - rvm: jruby-19mode - rvm: rbx-19mode sidekiq-cron-0.6.3/docker-compose.yml0000644000004100000410000000065613124502443017602 0ustar www-datawww-dataversion: '2' services: common: build: context: . image: sidekiq-cron-test environment: &environment - REDIS_URL=redis://redis.test:6379/0 dns: - 8.8.8.8 - 8.8.4.4 redis: image: redis tests: image: sidekiq-cron-test environment: *environment links: - redis:redis.test depends_on: - common command: dockerize -wait tcp://redis.test:6379 -timeout 60s rake test sidekiq-cron-0.6.3/lib/0000755000004100000410000000000013124502443014704 5ustar www-datawww-datasidekiq-cron-0.6.3/lib/sidekiq/0000755000004100000410000000000013124502443016335 5ustar www-datawww-datasidekiq-cron-0.6.3/lib/sidekiq/cron.rb0000644000004100000410000000020113124502443017614 0ustar www-datawww-datarequire "sidekiq/cron/job" require "sidekiq/cron/poller" require "sidekiq/cron/launcher" module Sidekiq module Cron end end sidekiq-cron-0.6.3/lib/sidekiq/cron/0000755000004100000410000000000013124502443017276 5ustar www-datawww-datasidekiq-cron-0.6.3/lib/sidekiq/cron/views/0000755000004100000410000000000013124502443020433 5ustar www-datawww-datasidekiq-cron-0.6.3/lib/sidekiq/cron/views/cron.slim0000644000004100000410000000705213124502443022266 0ustar www-datawww-dataheader.row .span.col-sm-5.pull-left h3 = t('CronJobs') .span.col-sm-7.pull-right style="margin-top: 20px; margin-bottom: 10px;" form.pull-right action="#{root_path}cron/__all__/delete" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left.btn-danger type="submit" name="enque" value="#{t('DeleteAll')}" data-confirm="#{t('AreYouSureDeleteCronJobs')}" form.pull-right action="#{root_path}cron/__all__/disable" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="enque" value="#{t('DisableAll')}" form.pull-right action="#{root_path}cron/__all__/enable" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="enque" value="#{t('EnableAll')}" form.pull-right action="#{root_path}cron/__all__/enque" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="enque" value="#{t('EnqueueAll')}" - if @cron_jobs.size > 0 table class="table table-hover table-bordered table-striped" thead th = t('Status') th = t('Name') th = t('Cron') th = t('Last enque') th width="180px" = t('Actions') - @cron_jobs.sort{|a,b| a.sort_name <=> b.sort_name }.each_with_index do |job, index| - style = "#{job.status == 'disabled' ? "background: #ecc": ""}" tr td[style="#{style}"]= job.status td[style="#{style}"] b job.name hr style="margin:3px;border:0;" small - if job.message and job.message.to_s.size > 100 button data-toggle="collapse" data-target=".worker_#{index}" class="btn btn-mini" = t('ShowAll') .toggle[class="worker_#{index}" style="display: inline;"]= job.message[0..100] + "... " .toggle[class="worker_#{index}" style="display: none;"]= job.message - else = job.message td[style="#{style}"] b == job.cron.gsub(" ", " ") td[style="#{style}"]== job.last_enqueue_time ? relative_time(job.last_enqueue_time) : "-" td[style="#{style}"] -if job.status == 'enabled' form action="#{root_path}cron/#{CGI.escape(job.name).gsub('+', '%20')}/enque" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="enque" value="#{t('EnqueueNow')}" form action="#{root_path}cron/#{CGI.escape(job.name).gsub('+', '%20')}/disable" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="disable" value="#{t('Disable')}" -else form action="#{root_path}cron/#{CGI.escape(job.name).gsub('+', '%20')}/enque" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="enque" value="#{t('EnqueueNow')}" form action="#{root_path}cron/#{CGI.escape(job.name).gsub('+', '%20')}/enable" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-small.pull-left type="submit" name="enable" value="#{t('Enable')}" form action="#{root_path}cron/#{CGI.escape(job.name).gsub('+', '%20')}/delete" method="post" = csrf_tag if respond_to?(:csrf_tag) input.btn.btn-danger.btn-small type="submit" name="delete" value="#{t('Delete')}" data-confirm="#{t('AreYouSureDeleteCronJob', :job => job.name)}" - else .alert.alert-success = t('NoCronJobsFound') sidekiq-cron-0.6.3/lib/sidekiq/cron/views/cron.erb0000644000004100000410000001075613124502443022077 0ustar www-datawww-data

<%=t 'CronJobs' %>

<%= csrf_tag if respond_to?(:csrf_tag) %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<% if @cron_jobs.size > 0 %> <% @cron_jobs.sort{|a,b| a.sort_name <=> b.sort_name }.each_with_index do |job, index| %> <% style = "#{job.status == 'disabled' ? "background: #ecc": ""}" %> <% end %>
<%= t('Status') %> <%= t('Name') %> <%= t('Cron string') %> <%= t('Last enque') %> <%= t('Actions')%>
<%= t job.status %> <%= job.name %>
<% if job.message and job.message.to_s.size > 100 %>
<%= job.message[0..100] + "... " %>
<% else %> <%= job.message %> <% end %>
<%= job.cron.gsub(" ", " ") %> <%= job.last_enqueue_time ? relative_time(job.last_enqueue_time) : "-" %> <% if job.status == 'enabled' %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<% else %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<%= csrf_tag if respond_to?(:csrf_tag) %>
<% end %>
<% else %> <%= t('NoCronJobsFound') %> <% end %> sidekiq-cron-0.6.3/lib/sidekiq/cron/web_extension.rb0000644000004100000410000000371713124502443022504 0ustar www-datawww-datamodule Sidekiq module Cron module WebExtension def self.registered(app) app.settings.locales << File.join(File.expand_path("..", __FILE__), "locales") #index page of cron jobs app.get '/cron' do view_path = File.join(File.expand_path("..", __FILE__), "views") @cron_jobs = Sidekiq::Cron::Job.all #if Slim renderer exists and sidekiq has layout.slim in views if defined?(Slim) && File.exists?(File.join(settings.views,"layout.slim")) render(:slim, File.read(File.join(view_path, "cron.slim"))) else render(:erb, File.read(File.join(view_path, "cron.erb"))) end end #enque cron job app.post '/cron/:name/enque' do if route_params[:name] === '__all__' Sidekiq::Cron::Job.all.each(&:enque!) elsif job = Sidekiq::Cron::Job.find(route_params[:name]) job.enque! end redirect "#{root_path}cron" end #delete schedule app.post '/cron/:name/delete' do if route_params[:name] === '__all__' Sidekiq::Cron::Job.all.each(&:destroy) elsif job = Sidekiq::Cron::Job.find(route_params[:name]) job.destroy end redirect "#{root_path}cron" end #enable job app.post '/cron/:name/enable' do if route_params[:name] === '__all__' Sidekiq::Cron::Job.all.each(&:enable!) elsif job = Sidekiq::Cron::Job.find(route_params[:name]) job.enable! end redirect "#{root_path}cron" end #disable job app.post '/cron/:name/disable' do if route_params[:name] === '__all__' Sidekiq::Cron::Job.all.each(&:disable!) elsif job = Sidekiq::Cron::Job.find(route_params[:name]) job.disable! end redirect "#{root_path}cron" end end end end end sidekiq-cron-0.6.3/lib/sidekiq/cron/job.rb0000644000004100000410000004152113124502443020400 0ustar www-datawww-datarequire 'sidekiq' require 'sidekiq/util' require 'rufus-scheduler' require 'sidekiq/cron/support' module Sidekiq module Cron class Job include Util extend Util #how long we would like to store informations about previous enqueues REMEMBER_THRESHOLD = 24 * 60 * 60 #crucial part of whole enquing job def should_enque? time enqueue = false enqueue = Sidekiq.redis do |conn| status == "enabled" && not_past_scheduled_time?(time) && not_enqueued_after?(time) && conn.zadd(job_enqueued_key, formated_enqueue_time(time), formated_last_time(time)) end enqueue end # remove previous informations about run times # this will clear redis and make sure that redis will # not overflow with memory def remove_previous_enques time Sidekiq.redis do |conn| conn.zremrangebyscore(job_enqueued_key, 0, "(#{(time.to_f - REMEMBER_THRESHOLD).to_s}") end end #test if job should be enqued If yes add it to queue def test_and_enque_for_time! time #should this job be enqued? if should_enque?(time) enque! remove_previous_enques(time) end end #enque cron job to queue def enque! time = Time.now.utc @last_enqueue_time = time klass_const = begin Sidekiq::Cron::Support.constantize(@klass.to_s) rescue NameError nil end if klass_const if defined?(ActiveJob::Base) && klass_const < ActiveJob::Base enqueue_active_job(klass_const) else enqueue_sidekiq_worker(klass_const) end else if @active_job Sidekiq::Client.push(active_job_message) else Sidekiq::Client.push(sidekiq_worker_message) end end save_last_enqueue_time logger.debug { "enqueued #{@name}: #{@message}" } end def is_active_job? @active_job || defined?(ActiveJob::Base) && Sidekiq::Cron::Support.constantize(@klass.to_s) < ActiveJob::Base rescue NameError false end def enqueue_active_job(klass_const) klass_const.set(queue: @queue).perform_later(*@args) true end def enqueue_sidekiq_worker(klass_const) klass_const.set(queue: queue_name_with_prefix).perform_async(*@args) true end # siodekiq worker message def sidekiq_worker_message @message.is_a?(String) ? Sidekiq.load_json(@message) : @message end def queue_name_with_prefix return @queue unless is_active_job? if !"#{@active_job_queue_name_delimiter}".empty? queue_name_delimiter = @active_job_queue_name_delimiter elsif defined?(ActiveJob::Base) && defined?(ActiveJob::Base.queue_name_delimiter) && !ActiveJob::Base.queue_name_delimiter.empty? queue_name_delimiter = ActiveJob::Base.queue_name_delimiter else queue_name_delimiter = '_' end if !"#{@active_job_queue_name_prefix}".empty? queue_name = "#{@active_job_queue_name_prefix}#{queue_name_delimiter}#{@queue}" elsif defined?(ActiveJob::Base) && defined?(ActiveJob::Base.queue_name_prefix) && !"#{ActiveJob::Base.queue_name_prefix}".empty? queue_name = "#{ActiveJob::Base.queue_name_prefix}#{queue_name_delimiter}#{@queue}" else queue_name = @queue end queue_name end # active job has different structure how it is loading data from sidekiq # queue, it createaswrapper arround job def active_job_message { 'class' => 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', 'queue' => @queue_name_with_prefix, 'description' => @description, 'args' => [{ 'job_class' => @klass, 'job_id' => SecureRandom.uuid, 'queue_name' => @queue_name_with_prefix, 'arguments' => @args }] } end # load cron jobs from Hash # input structure should look like: # { # 'name_of_job' => { # 'class' => 'MyClass', # 'cron' => '1 * * * *', # 'args' => '(OPTIONAL) [Array or Hash]', # 'description' => '(OPTIONAL) Description of job' # }, # 'My super iber cool job' => { # 'class' => 'SecondClass', # 'cron' => '*/5 * * * *' # } # } # def self.load_from_hash hash array = hash.inject([]) do |out,(key, job)| job['name'] = key out << job end load_from_array array end # like to {#load_from_hash} # If exists old jobs in redis but removed from args, destroy old jobs def self.load_from_hash! hash destroy_removed_jobs(hash.keys) load_from_hash(hash) end # load cron jobs from Array # input structure should look like: # [ # { # 'name' => 'name_of_job', # 'class' => 'MyClass', # 'cron' => '1 * * * *', # 'args' => '(OPTIONAL) [Array or Hash]', # 'description' => '(OPTIONAL) Description of job' # }, # { # 'name' => 'Cool Job for Second Class', # 'class' => 'SecondClass', # 'cron' => '*/5 * * * *' # } # ] # def self.load_from_array array errors = {} array.each do |job_data| job = new(job_data) errors[job.name] = job.errors unless job.save end errors end # like to {#load_from_array} # If exists old jobs in redis but removed from args, destroy old jobs def self.load_from_array! array job_names = array.map { |job| job["name"] } destroy_removed_jobs(job_names) load_from_array(array) end # get all cron jobs def self.all job_hashes = nil Sidekiq.redis do |conn| set_members = conn.smembers(jobs_key) job_hashes = conn.pipelined do set_members.each do |key| conn.hgetall(key) end end end job_hashes.compact.reject(&:empty?).collect do |h| # no need to fetch missing args from redis since we just got this hash from there Sidekiq::Cron::Job.new(h.merge(fetch_missing_args: false)) end end def self.count out = 0 Sidekiq.redis do |conn| out = conn.scard(jobs_key) end out end def self.find name #if name is hash try to get name from it name = name[:name] || name['name'] if name.is_a?(Hash) output = nil Sidekiq.redis do |conn| if exists? name output = Job.new conn.hgetall( redis_key(name) ) end end output end # create new instance of cron job def self.create hash new(hash).save end #destroy job by name def self.destroy name #if name is hash try to get name from it name = name[:name] || name['name'] if name.is_a?(Hash) if job = find(name) job.destroy else false end end attr_accessor :name, :cron, :description, :klass, :args, :message attr_reader :last_enqueue_time, :fetch_missing_args def initialize input_args = {} args = Hash[input_args.map{ |k, v| [k.to_s, v] }] @fetch_missing_args = args.delete('fetch_missing_args') @fetch_missing_args = true if @fetch_missing_args.nil? @name = args["name"] @cron = args["cron"] @description = args["description"] if args["description"] #get class from klass or class @klass = args["klass"] || args["class"] #set status of job @status = args['status'] || status_from_redis #set last enqueue time - from args or from existing job if args['last_enqueue_time'] && !args['last_enqueue_time'].empty? @last_enqueue_time = Time.parse(args['last_enqueue_time']) else @last_enqueue_time = last_enqueue_time_from_redis end #get right arguments for job @args = args["args"].nil? ? [] : parse_args( args["args"] ) @active_job = args["active_job"] == true || ("#{args["active_job"]}" =~ (/^(true|t|yes|y|1)$/i)) == 0 || false @active_job_queue_name_prefix = args["queue_name_prefix"] @active_job_queue_name_delimiter = args["queue_name_delimiter"] if args["message"] @message = args["message"] message_data = Sidekiq.load_json(@message) || {} @queue = message_data['queue'] || "default" elsif @klass message_data = { "class" => @klass.to_s, "args" => @args, } #get right data for message #only if message wasn't specified before klass_data = case @klass when Class @klass.get_sidekiq_options when String begin Sidekiq::Cron::Support.constantize(@klass).get_sidekiq_options rescue Exception => e #Unknown class {"queue"=>"default"} end end message_data = klass_data.merge(message_data) #override queue if setted in config #only if message is hash - can be string (dumped JSON) if args['queue'] @queue = message_data['queue'] = args['queue'] else @queue = message_data['queue'] || "default" end #dump message as json @message = message_data end @queue_name_with_prefix = queue_name_with_prefix end def status @status end def disable! @status = "disabled" save end def enable! @status = "enabled" save end def enabled? @status == "enabled" end def disabled? !enabled? end def status_from_redis out = "enabled" if fetch_missing_args Sidekiq.redis do |conn| status = conn.hget redis_key, "status" out = status if status end end out end def last_enqueue_time_from_redis out = nil if fetch_missing_args Sidekiq.redis do |conn| out = Time.parse(conn.hget(redis_key, "last_enqueue_time")) rescue nil end end out end #export job data to hash def to_hash { name: @name, klass: @klass, cron: @cron, description: @description, args: @args.is_a?(String) ? @args : Sidekiq.dump_json(@args || []), message: @message.is_a?(String) ? @message : Sidekiq.dump_json(@message || {}), status: @status, active_job: @active_job, queue_name_prefix: @active_job_queue_name_prefix, queue_name_delimiter: @active_job_queue_name_delimiter, last_enqueue_time: @last_enqueue_time, } end def errors @errors ||= [] end def valid? #clear previos errors @errors = [] errors << "'name' must be set" if @name.nil? || @name.size == 0 if @cron.nil? || @cron.size == 0 errors << "'cron' must be set" else begin cron = Rufus::Scheduler::CronLine.new(@cron) cron.next_time(Time.now.utc).utc rescue Exception => e #fix for different versions of cron-parser if e.message == "Bad Vixie-style specification bad" errors << "'cron' -> #{@cron}: not a valid cronline" else errors << "'cron' -> #{@cron}: #{e.message}" end end end errors << "'klass' (or class) must be set" unless klass_valid !errors.any? end def klass_valid case @klass when Class true when String @klass.size > 0 else end end # add job to cron jobs # input: # name: (string) - name of job # cron: (string: '* * * * *' - cron specification when to run job # class: (string|class) - which class to perform # optional input: # queue: (string) - which queue to use for enquing (will override class queue) # args: (array|hash|nil) - arguments for permorm method def save #if job is invalid return false return false unless valid? Sidekiq.redis do |conn| #add to set of all jobs conn.sadd self.class.jobs_key, redis_key #add informations for this job! conn.hmset redis_key, *hash_to_redis(to_hash) #add information about last time! - don't enque right after scheduler poller starts! time = Time.now.utc conn.zadd(job_enqueued_key, time.to_f.to_s, formated_last_time(time).to_s) unless conn.exists(job_enqueued_key) end logger.info { "Cron Jobs - add job with name: #{@name}" } end def save_last_enqueue_time Sidekiq.redis do |conn| # update last enqueue time conn.hset redis_key, 'last_enqueue_time', @last_enqueue_time end end # remove job from cron jobs by name # input: # first arg: name (string) - name of job (must be same - case sensitive) def destroy Sidekiq.redis do |conn| #delete from set conn.srem self.class.jobs_key, redis_key #delete runned timestamps conn.del job_enqueued_key #delete main job conn.del redis_key end logger.info { "Cron Jobs - deleted job with name: #{@name}" } end # remove all job from cron def self.destroy_all! all.each do |job| job.destroy end logger.info { "Cron Jobs - deleted all jobs" } end # remove "removed jobs" between current jobs and new jobs def self.destroy_removed_jobs new_job_names current_job_names = Sidekiq::Cron::Job.all.map(&:name) removed_job_names = current_job_names - new_job_names removed_job_names.each { |j| Sidekiq::Cron::Job.destroy(j) } removed_job_names end # Parse cron specification '* * * * *' and returns # time when last run should be performed def last_time now = Time.now.utc Rufus::Scheduler::CronLine.new(@cron).previous_time(now.utc).utc end def formated_enqueue_time now = Time.now.utc last_time(now).getutc.to_f.to_s end def formated_last_time now = Time.now.utc last_time(now).getutc.iso8601 end def self.exists? name out = false Sidekiq.redis do |conn| out = conn.exists redis_key name end out end def exists? self.class.exists? @name end def sort_name "#{status == "enabled" ? 0 : 1}_#{name}".downcase end private def not_enqueued_after?(time) @last_enqueue_time.nil? || @last_enqueue_time.to_i < last_time(time).to_i end # Try parsing inbound args into an array. # args from Redis will be encoded JSON; # try to load JSON, then failover # to string array. def parse_args(args) case args when String begin Sidekiq.load_json(args) rescue JSON::ParserError [*args] # cast to string array end when Hash [args] # just put hash into array when Array args # do nothing, already array else [*args] # cast to string array end end def not_past_scheduled_time?(current_time) last_cron_time = Rufus::Scheduler::CronLine.new(@cron).previous_time(current_time).utc return false if (current_time.to_i - last_cron_time.to_i) > 60 true end # Redis key for set of all cron jobs def self.jobs_key "cron_jobs" end # Redis key for storing one cron job def self.redis_key name "cron_job:#{name}" end # Redis key for storing one cron job def redis_key self.class.redis_key @name end # Redis key for storing one cron job run times # (when poller added job to queue) def self.job_enqueued_key name "cron_job:#{name}:enqueued" end # Redis key for storing one cron job run times # (when poller added job to queue) def job_enqueued_key self.class.job_enqueued_key @name end # Give Hash # returns array for using it for redis.hmset def hash_to_redis hash hash.inject([]){ |arr,kv| arr + [kv[0], kv[1]] } end end end end sidekiq-cron-0.6.3/lib/sidekiq/cron/support.rb0000644000004100000410000000261613124502443021344 0ustar www-datawww-data# https://github.com/rails/rails/blob/352865d0f835c24daa9a2e9863dcc9dde9e5371a/activesupport/lib/active_support/inflector/methods.rb#L270 module Sidekiq module Cron module Support def self.constantize(camel_cased_word) names = camel_cased_word.split("::".freeze) # Trigger a built-in NameError exception including the ill-formed constant in the message. Object.const_get(camel_cased_word) if names.empty? # Remove the first blank element in case of '::ClassName' notation. names.shift if names.size > 1 && names.first.empty? names.inject(Object) do |constant, name| if constant == Object constant.const_get(name) else candidate = constant.const_get(name) next candidate if constant.const_defined?(name, false) next candidate unless Object.const_defined?(name) # Go down the ancestors to check if it is owned directly. The check # stops when we reach Object or the end of ancestors tree. constant = constant.ancestors.inject(constant) do |const, ancestor| break const if ancestor == Object break ancestor if ancestor.const_defined?(name, false) const end # owner is in Object, so raise constant.const_get(name, false) end end end end end end sidekiq-cron-0.6.3/lib/sidekiq/cron/poller.rb0000644000004100000410000000177313124502443021130 0ustar www-datawww-datarequire 'sidekiq' require 'sidekiq/util' require 'sidekiq/cron' require 'sidekiq/scheduled' module Sidekiq module Cron POLL_INTERVAL = 30 # The Poller checks Redis every N seconds for sheduled cron jobs class Poller < Sidekiq::Scheduled::Poller def enqueue time = Time.now.utc Sidekiq::Cron::Job.all.each do |job| enqueue_job(job, time) end rescue => ex # Most likely a problem with redis networking. # Punt and try again at the next interval logger.error ex.message logger.error ex.backtrace.first end private def enqueue_job(job, time = Time.now.utc) job.test_and_enque_for_time! time if job && job.valid? rescue => ex # problem somewhere in one job logger.error "CRON JOB: #{ex.message}" logger.error "CRON JOB: #{ex.backtrace.first}" end def poll_interval_average Sidekiq.options[:poll_interval] || POLL_INTERVAL end end end end sidekiq-cron-0.6.3/lib/sidekiq/cron/locales/0000755000004100000410000000000013124502443020720 5ustar www-datawww-datasidekiq-cron-0.6.3/lib/sidekiq/cron/locales/en.yml0000644000004100000410000000076313124502443022053 0ustar www-datawww-dataen: Job: Job Cron: Cron CronJobs: Cron Jobs EnqueueNow: Enqueue Now EnableAll: Enable All DisableAll: Disable All EnqueueAll: Enqueue All DeleteAll: Delete All 'Cron string': Cron AreYouSureDeleteCronJobs: Are you sure you want to delete ALL cron jobs? AreYouSureDeleteCronJob: Are you sure you want to delete the %{job} cron job? NoCronJobsFound: "No cron jobs found" Enable: Enable Disable: Disable 'Last enque': Last enqueued disabled: disabled enabled: enabled sidekiq-cron-0.6.3/lib/sidekiq/cron/locales/de.yml0000644000004100000410000000054113124502443022033 0ustar www-datawww-datade: Job: Job Cron: Cron CronJobs: Cronjobs EnqueueNow: In Warteschlange 'Cron string': Cron AreYouSureDeleteCronJob: Sind Sie sicher, dass sie den Cronjob %{job} löschen wollen? NoCronJobsFound: "Keine Cronjobs gefunden" Enable: Aktivieren Disable: Deaktivieren 'Last enque': Eingereiht disabled: deaktiviert enabled: aktiviert sidekiq-cron-0.6.3/lib/sidekiq/cron/locales/ru.yml0000644000004100000410000000107413124502443022073 0ustar www-datawww-dataru: Job: Задача Cron: Cron CronJobs: Периодические задачи Name: Название 'Cron string': Периодичность (синтаксис Cron) EnqueueNow: Запустить AreYouSureDeleteCronJob: Вы действительно хотите удалить задачу «%{job}»? NoCronJobsFound: "Не найдено периодических задач" Enable: Включить Disable: Отключить 'Last enque': Последний запуск disabled: отключено enabled: включено sidekiq-cron-0.6.3/lib/sidekiq/cron/web.rb0000644000004100000410000000044413124502443020402 0ustar www-datawww-datarequire "sidekiq/cron/web_extension" require "sidekiq/cron/job" if defined?(Sidekiq::Web) Sidekiq::Web.register Sidekiq::Cron::WebExtension if Sidekiq::Web.tabs.is_a?(Array) # For sidekiq < 2.5 Sidekiq::Web.tabs << "cron" else Sidekiq::Web.tabs["Cron"] = "cron" end end sidekiq-cron-0.6.3/lib/sidekiq/cron/launcher.rb0000644000004100000410000000226513124502443021431 0ustar www-datawww-data# require Sidekiq original launcher require 'sidekiq/launcher' # require cron poller require 'sidekiq/cron/poller' # For Cron we need to add some methods to Launcher # so look at the code bellow. # # we are creating new cron poller instance and # adding start and stop commands to launcher module Sidekiq class Launcher # Add cron poller to launcher attr_reader :cron_poller # remember old initialize alias_method :old_initialize, :initialize # add cron poller and execute normal initialize of Sidekiq launcher def initialize(options) @cron_poller = Sidekiq::Cron::Poller.new old_initialize options end # remember old run alias_method :old_run, :run # execute normal run of launcher and run cron poller def run old_run cron_poller.start end # remember old quiet alias_method :old_quiet, :quiet # execute normal quiet of launcher and quiet cron poller def quiet cron_poller.terminate old_quiet end # remember old stop alias_method :old_stop, :stop # execute normal stop of launcher and stop cron poller def stop cron_poller.terminate old_stop end end end sidekiq-cron-0.6.3/lib/sidekiq-cron.rb0000644000004100000410000000005113124502443017615 0ustar www-datawww-datarequire "sidekiq" require "sidekiq/cron" sidekiq-cron-0.6.3/test/0000755000004100000410000000000013124502443015115 5ustar www-datawww-datasidekiq-cron-0.6.3/test/test_helper.rb0000644000004100000410000000361213124502443017762 0ustar www-datawww-datarequire 'rubygems' require 'bundler' begin Bundler.setup(:default, :development) rescue Bundler::BundlerError => e $stderr.puts e.message $stderr.puts "Run `bundle install` to install missing gems" exit e.status_code end require 'simplecov' SimpleCov.start do add_filter "/test/" add_group 'SidekiqCron', 'lib/' end require 'coveralls' Coveralls.wear! require "minitest/autorun" require 'shoulda-context' require "rack/test" require "mocha/setup" ENV['RACK_ENV'] = 'test' #SIDEKIQ Require - need to have sidekiq running! require 'sidekiq' require 'sidekiq/util' require 'sidekiq/web' Sidekiq.logger.level = Logger::ERROR require 'sidekiq/redis_connection' redis_url = ENV['REDIS_URL'] || 'redis://0.0.0.0:6379' REDIS = Sidekiq::RedisConnection.create(:url => redis_url, :namespace => 'testy') Sidekiq.configure_client do |config| config.redis = { :url => redis_url, :namespace => 'testy' } end $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) $LOAD_PATH.unshift(File.dirname(__FILE__)) require 'sidekiq-cron' require 'sidekiq/cron/web' require 'pp' class CronTestClass include Sidekiq::Worker sidekiq_options retry: true def perform args = {} puts "super croned job #{args}" end end class CronTestClassWithQueue include Sidekiq::Worker sidekiq_options queue: :super, retry: false, backtrace: true def perform args = {} puts "super croned job #{args}" end end module ActiveJob class Base def self.queue_name_prefix @queue_name_prefix end def self.queue_name_prefix=(queue_name_prefix) @queue_name_prefix = queue_name_prefix end def self.set(options) @queue = options['queue'] self end def self.perform_later(*args) { "job_class" => self.class.name, "queue_name" => @queue, "args" => [*args], } end end end class ActiveJobCronTestClass < ActiveJob::Base end sidekiq-cron-0.6.3/test/integration/0000755000004100000410000000000013124502443017440 5ustar www-datawww-datasidekiq-cron-0.6.3/test/integration/performance_test.rb0000644000004100000410000000225313124502443023327 0ustar www-datawww-data# -*- encoding : utf-8 -*- require './test/test_helper' require 'benchmark' describe 'Perfromance Poller' do X = 10000 before do Sidekiq.redis = REDIS Sidekiq.redis do |conn| conn.flushdb end #clear all previous saved data from redis Sidekiq.redis do |conn| conn.keys("cron_job*").each do |key| conn.del(key) end end args = { queue: "default", cron: "*/2 * * * *", klass: "CronTestClass" } X.times do |i| Sidekiq::Cron::Job.create(args.merge(name: "Test#{i}")) end @poller = Sidekiq::Cron::Poller.new now = Time.now.utc enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 10, 5) Time.stubs(:now).returns(enqueued_time) end it 'should enqueue 10000 jobs in less than 30s' do Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default"), 'Queue should be empty' end bench = Benchmark.measure { @poller.enqueue } Sidekiq.redis do |conn| assert_equal X, conn.llen("queue:default"), 'Queue should be full' end puts "Perfomance test finished in #{bench.real}" assert_operator 30, :>, bench.real end end sidekiq-cron-0.6.3/test/unit/0000755000004100000410000000000013124502443016074 5ustar www-datawww-datasidekiq-cron-0.6.3/test/unit/web_extension_test.rb0000644000004100000410000000701013124502443022327 0ustar www-datawww-datarequire './test/test_helper' def app Sidekiq::Web end describe 'Cron web' do include Rack::Test::Methods before do Sidekiq.redis = REDIS Sidekiq.redis {|c| c.flushdb } #clear all previous saved data from redis Sidekiq.redis do |conn| conn.keys("cron_job*").each do |key| conn.del(key) end end @args = { name: "TestNameOfCronJob", cron: "*/2 * * * *", klass: "CronTestClass" } @cron_args = { name: "TesQueueNameOfCronJob", cron: "*/2 * * * *", klass: "CronQueueTestClass", queue: "cron" } end it 'display cron web' do get '/cron' assert_equal 200, last_response.status end it 'display cron web with message - no cron jobs' do get '/cron' assert last_response.body.include?('No cron jobs found') end it 'display cron web with cron jobs table' do Sidekiq::Cron::Job.create(@args) get '/cron' assert_equal 200, last_response.status refute last_response.body.include?('No cron jobs found') assert last_response.body.include?('table') assert last_response.body.include?("TestNameOfCronJob") end describe "work with cron job" do before do @job = Sidekiq::Cron::Job.new(@args.merge(status: "enabled")) @job.save @name = "TestNameOfCronJob" @cron_job = Sidekiq::Cron::Job.new(@cron_args.merge(status: "enabled")) @cron_job.save @cron_job_name = "TesQueueNameOfCronJob" end it "disable and enable all cron jobs" do post "/cron/__all__/disable" assert_equal Sidekiq::Cron::Job.find(@name).status, "disabled" post "/cron/__all__/enable" assert_equal Sidekiq::Cron::Job.find(@name).status, "enabled" end it "disable and enable cron job" do post "/cron/#{@name}/disable" assert_equal Sidekiq::Cron::Job.find(@name).status, "disabled" post "/cron/#{@name}/enable" assert_equal Sidekiq::Cron::Job.find(@name).status, "enabled" end it "enqueue all jobs" do Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default"), "Queue should have no jobs" end post "/cron/__all__/enque" Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default"), "Queue should have 1 job in default" assert_equal 1, conn.llen("queue:cron"), "Queue should have 1 job in cron" end end it "enqueue job" do Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default"), "Queue should have no jobs" end post "/cron/#{@name}/enque" Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default"), "Queue should have 1 job" end #should enqueue more times post "/cron/#{@name}/enque" Sidekiq.redis do |conn| assert_equal 2, conn.llen("queue:default"), "Queue should have 2 job" end #should enqueue to cron job queue post "/cron/#{@cron_job_name}/enque" Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:cron"), "Queue should have 1 cron job" end end it "destroy job" do assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 job" post "/cron/#{@name}/delete" post "/cron/#{@cron_job_name}/delete" assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have zero jobs" end it "destroy all jobs" do assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 job" post "/cron/__all__/delete" assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have zero jobs" end end end sidekiq-cron-0.6.3/test/unit/job_test.rb0000644000004100000410000007272313124502443020245 0ustar www-datawww-data# -*- encoding : utf-8 -*- require './test/test_helper' describe "Cron Job" do before do #clear all previous saved data from redis Sidekiq.redis do |conn| conn.keys("cron_job*").each do |key| conn.del(key) end end #clear all queues Sidekiq::Queue.all.each do |queue| queue.clear end end it "be initialized" do job = Sidekiq::Cron::Job.new() assert_nil job.last_enqueue_time assert job.is_a?(Sidekiq::Cron::Job) end describe "class methods" do it "have create method" do assert Sidekiq::Cron::Job.respond_to?(:create) end it "have destroy method" do assert Sidekiq::Cron::Job.respond_to?(:destroy) end it "have count" do assert Sidekiq::Cron::Job.respond_to?(:count) end it "have all" do assert Sidekiq::Cron::Job.respond_to?(:all) end it "have find" do assert Sidekiq::Cron::Job.respond_to?(:find) end end describe "instance methods" do before do @job = Sidekiq::Cron::Job.new() end it "have save method" do assert @job.respond_to?(:save) end it "have valid? method" do assert @job.respond_to?("valid?".to_sym) end it "have destroy method" do assert @job.respond_to?(:destroy) end it "have enabled? method" do assert @job.respond_to?(:enabled?) end it "have disabled? method" do assert @job.respond_to?(:disabled?) end it 'have sort_name - used for sorting enabled disbaled jobs on frontend' do job = Sidekiq::Cron::Job.new(name: "TestName") assert_equal job.sort_name, "0_testname" end end describe "invalid job" do before do @job = Sidekiq::Cron::Job.new() end it "allow a class instance for the klass" do @job.klass = CronTestClass refute @job.valid? refute @job.errors.any?{|e| e.include?("klass")}, "Should not have error for klass" end it "return false on valid? and errors" do refute @job.valid? assert @job.errors.is_a?(Array) assert @job.errors.any?{|e| e.include?("name")}, "Should have error for name" assert @job.errors.any?{|e| e.include?("cron")}, "Should have error for cron" assert @job.errors.any?{|e| e.include?("klass")}, "Should have error for klass" end it "return false on valid? with invalid cron" do @job.cron = "* s *" refute @job.valid? assert @job.errors.is_a?(Array) assert @job.errors.any?{|e| e.include?("cron")}, "Should have error for cron" end it "return false on save" do refute @job.save end end describe "new" do before do @args = { name: "Test", cron: "* * * * *" } @job = Sidekiq::Cron::Job.new(@args) end it "have all setted attributes" do @args.each do |key, value| assert_equal @job.send(key), value, "New job should have #{key} with value #{value} but it has: #{@job.send(key)}" end end it "have to_hash method" do [:name,:klass,:cron,:description,:args,:message,:status].each do |key| assert @job.to_hash.has_key?(key), "to_hash must have key: #{key}" end end end describe 'formatted time' do before do @args = { name: "Test", cron: "* * * * *" } @job = Sidekiq::Cron::Job.new(@args) @time = Time.new(2015, 1, 2, 3, 4, 5, '+01:00') end it 'returns formatted_last_time' do assert_equal '2015-01-02T02:04:00Z', @job.formated_last_time(@time) end it 'returns formated_enqueue_time' do assert_equal '1420164240.0', @job.formated_enqueue_time(@time) end end describe "new with different class inputs" do it "be initialized by 'klass' and Class" do job = Sidekiq::Cron::Job.new('klass' => CronTestClass) assert_equal job.message['class'], 'CronTestClass' end it "be initialized by 'klass' and string Class" do job = Sidekiq::Cron::Job.new('klass' => 'CronTestClass') assert_equal job.message['class'], 'CronTestClass' end it "be initialized by 'class' and string Class" do job = Sidekiq::Cron::Job.new('class' => 'CronTestClass') assert_equal job.message['class'], 'CronTestClass' end it "be initialized by 'class' and Class" do job = Sidekiq::Cron::Job.new('class' => CronTestClass) assert_equal job.message['class'], 'CronTestClass' end end describe "new should find klass specific settings (queue, retry ...)" do it "nothing raise on unknown klass" do job = Sidekiq::Cron::Job.new('klass' => 'UnknownCronClass') assert_equal job.message, {"class"=>"UnknownCronClass", "args"=>[], "queue"=>"default"} end it "be initialized with default attributes" do job = Sidekiq::Cron::Job.new('klass' => 'CronTestClass') assert_equal job.message, {"retry"=>true, "queue"=>"default", "class"=>"CronTestClass", "args"=>[]} end it "be initialized with class specified attributes" do job = Sidekiq::Cron::Job.new('class' => 'CronTestClassWithQueue') assert_equal job.message, {"retry"=>false, "queue"=>:super, "backtrace"=>true, "class"=>"CronTestClassWithQueue", "args"=>[]} end it "be initialized with 'class' and overwrite queue by settings" do job = Sidekiq::Cron::Job.new('class' => CronTestClassWithQueue, queue: 'my_testing_queue') assert_equal job.message, {"retry"=>false, "queue"=>'my_testing_queue', "backtrace"=>true, "class"=>"CronTestClassWithQueue", "args"=>[]} end end describe "cron test" do before do @job = Sidekiq::Cron::Job.new() end it "return previous minute" do @job.cron = "* * * * *" time = Time.now.utc assert_equal @job.last_time(time).strftime("%Y-%m-%d-%H-%M-%S"), time.strftime("%Y-%m-%d-%H-%M-00") end it "return previous hour" do @job.cron = "1 * * * *" time = Time.now.utc assert_equal @job.last_time(time).strftime("%Y-%m-%d-%H-%M-%S"), time.strftime("%Y-%m-%d-%H-01-00") end it "return previous day" do @job.cron = "1 2 * * * Etc/GMT" time = Time.now.utc if time.hour >= 2 assert_equal @job.last_time(time).strftime("%Y-%m-%d-%H-%M-%S"), time.strftime("%Y-%m-%d-02-01-00") else yesterday = (Date.today - 1) assert_equal @job.last_time(time).strftime("%Y-%m-%d-%H-%M-%S"), yesterday.strftime("%Y-%m-%d-02-01-00") end end end describe '#sidekiq_worker_message' do before do @args = { name: 'Test', cron: '* * * * *', queue: 'super_queue', klass: 'CronTestClass', args: { foo: 'bar' } } @job = Sidekiq::Cron::Job.new(@args) end it 'should return valid payload for Sidekiq::Client' do payload = { "retry" => true, "queue" => "super_queue", "class" => "CronTestClass", "args" => [{:foo=>"bar"}] } assert_equal @job.sidekiq_worker_message, payload end end describe '#sidekiq_worker_message settings overwrite queue name' do before do @args = { name: 'Test', cron: '* * * * *', queue: 'super_queue', klass: 'CronTestClassWithQueue', args: { foo: 'bar' } } @job = Sidekiq::Cron::Job.new(@args) end it 'should return valid payload for Sidekiq::Client with overwrite queue name' do payload = { "retry" => false, "backtrace"=>true, "queue" => "super_queue", "class" => "CronTestClassWithQueue", "args" => [{:foo=>"bar"}] } assert_equal @job.sidekiq_worker_message, payload end end describe '#active_job_message' do before do SecureRandom.stubs(:uuid).returns('XYZ') ActiveJob::Base.queue_name_prefix = '' @args = { name: 'Test', cron: '* * * * *', klass: 'ActiveJobCronTestClass', queue: 'super_queue', description: nil, args: { foo: 'bar' } } @job = Sidekiq::Cron::Job.new(@args) end it 'should return valid payload for Sidekiq::Client' do payload = { 'class' => 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', 'queue' => 'super_queue', 'description' => nil, 'args' => [{ 'job_class' => 'ActiveJobCronTestClass', 'job_id' => 'XYZ', 'queue_name' => 'super_queue', 'arguments' => [{foo: 'bar'}] }] } assert_equal @job.active_job_message, payload end end describe '#active_job_message with queue_name_prefix' do before do SecureRandom.stubs(:uuid).returns('XYZ') ActiveJob::Base.queue_name_prefix = "prefix" @args = { name: 'Test', cron: '* * * * *', klass: 'ActiveJobCronTestClass', queue: 'super_queue', queue_name_prefix: 'prefix', args: { foo: 'bar' } } @job = Sidekiq::Cron::Job.new(@args) end it 'should return valid payload for Sidekiq::Client' do payload = { 'class' => 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', 'queue' => 'prefix_super_queue', 'description' => nil, 'args' =>[{ 'job_class' => 'ActiveJobCronTestClass', 'job_id' => 'XYZ', 'queue_name' => 'prefix_super_queue', 'arguments' => [{foo: 'bar'}] }] } assert_equal @job.active_job_message, payload end end describe '#enque!' do describe 'active job' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'ActiveJobCronTestClass' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message' do @job.expects(:enqueue_active_job) .returns(true) @job.enque! end end describe 'active job with queue_name_prefix' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'ActiveJobCronTestClass', queue: 'cron' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message with queue_name_prefix' do @job.expects(:enqueue_active_job) .returns(true) @job.enque! end end describe 'active job via configuration (bool: true) [unknown class]' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'UnknownClass', active_job: true } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message' do @job.expects(:active_job_message) .returns('class' => 'UnknownClass', 'args' => []) @job.enque! end end describe 'active job via configuration (string: true) [unknown class]' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'UnknownClass', active_job: 'true' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message' do @job.expects(:active_job_message) .returns('class' => 'UnknownClass', 'args' => []) @job.enque! end end describe 'active job via configuration (string: yes) [unknown class]' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'UnknownClass', active_job: 'yes' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message' do @job.expects(:active_job_message) .returns('class' => 'UnknownClass', 'args' => []) @job.enque! end end describe 'active job via configuration (number: 1) [unknown class]' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'UnknownClass', active_job: 1 } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message' do @job.expects(:active_job_message) .returns('class' => 'UnknownClass', 'args' => []) @job.enque! end end describe 'active job via configuration with queue_name_prefix option [unknown class]' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'UnknownClass', queue: 'cron', active_job: true, queue_name_prefix: 'prefix' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message with queue_name_prefix' do @job.expects(:active_job_message) .returns('class' => 'UnknownClass', 'args' => [], 'queue' => 'prefix_cron') @job.enque! end end describe 'sidekiq worker' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'CronTestClass' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue active jobs message' do @job.expects(:enqueue_sidekiq_worker) .returns(true) @job.enque! end end describe 'sidekiq worker unknown class' do before do @args = { name: 'Test', cron: '* * * * *', klass: 'UnknownClass', queue: 'another' } @job = Sidekiq::Cron::Job.new(@args) end it 'pushes to queue sidekiq worker message' do @job.expects(:sidekiq_worker_message) .returns('class' => 'UnknownClass', 'args' => [], 'queue' => 'another') @job.enque! end end end describe "save" do before do @args = { name: "Test", cron: "* * * * *", klass: "CronTestClass" } @job = Sidekiq::Cron::Job.new(@args) end it "be saved" do assert @job.save end it "be saved and found by name" do assert @job.save, "not saved" assert Sidekiq::Cron::Job.find("Test").is_a?(Sidekiq::Cron::Job) end end describe "nonexisting job" do it "not be found" do assert Sidekiq::Cron::Job.find("nonexisting").nil?, "should return nil" end end describe "disabled/enabled" do before do @args = { name: "Test", cron: "* * * * *", klass: "CronTestClass" } end it "be created and enabled" do Sidekiq::Cron::Job.create(@args) job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "enabled" end it "be created and then enabled and disabled" do Sidekiq::Cron::Job.create(@args) job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "enabled" job.enable! assert_equal job.status, "enabled" job.disable! assert_equal job.status, "disabled" end it "be created with status disabled" do Sidekiq::Cron::Job.create(@args.merge(status: "disabled")) job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "disabled" assert_equal job.disabled?, true assert_equal job.enabled?, false end it "be created with status enabled and disable it afterwards" do Sidekiq::Cron::Job.create(@args) job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "enabled" assert_equal job.enabled?, true job.disable! assert_equal job.status, "disabled", "directly after call" assert_equal job.disabled?, true job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "disabled", "after find" end it "status shouldn't be rewritten after save without status" do Sidekiq::Cron::Job.create(@args) job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "enabled" job.disable! assert_equal job.status, "disabled", "directly after call" job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "disabled", "after find" Sidekiq::Cron::Job.create(@args) assert_equal job.status, "disabled", "after second create" job = Sidekiq::Cron::Job.find(@args) assert_equal job.status, "disabled", "after second find" end it "last_enqueue_time shouldn't be rewritten after save" do #adding last_enqueue_time to initialize is only for test purpose last_enqueue_time = '2013-01-01 23:59:59' Sidekiq::Cron::Job.create(@args.merge('last_enqueue_time' => last_enqueue_time)) job = Sidekiq::Cron::Job.find(@args) assert_equal job.last_enqueue_time, Time.parse(last_enqueue_time) Sidekiq::Cron::Job.create(@args) job = Sidekiq::Cron::Job.find(@args) assert_equal job.last_enqueue_time, Time.parse(last_enqueue_time), "after second create should have same time" end end describe "initialize args" do it "from JSON" do args = { name: "Test", cron: "* * * * *", klass: "CronTestClass", args: JSON.dump(["123"]) } Sidekiq::Cron::Job.new(args).tap do |job| assert_equal job.args, ["123"] assert_equal job.name, "Test" end end it "from String" do args = { name: "Test", cron: "* * * * *", klass: "CronTestClass", args: "(my funny string)" } Sidekiq::Cron::Job.new(args).tap do |job| assert_equal job.args, ["(my funny string)"] assert_equal job.name, "Test" end end it "from Array" do args = { name: "Test", cron: "* * * * *", klass: "CronTestClass", args: ["This is array"] } Sidekiq::Cron::Job.new(args).tap do |job| assert_equal job.args, ["This is array"] assert_equal job.name, "Test" end end end describe "create & find methods" do before do @args = { name: "Test", cron: "* * * * *", klass: "CronTestClass" } end it "create first three jobs" do assert_equal Sidekiq::Cron::Job.count, 0, "Should have 0 jobs" Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args.merge(name: "Test2")) Sidekiq::Cron::Job.create(@args.merge(name: "Test3")) assert_equal Sidekiq::Cron::Job.count, 3, "Should have 3 jobs" end it "create first three jobs - 1 has same name" do assert_equal Sidekiq::Cron::Job.count, 0, "Should have 0 jobs" Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args.merge(name: "Test2")) Sidekiq::Cron::Job.create(@args.merge(cron: "1 * * * *")) assert_equal Sidekiq::Cron::Job.count, 2, "Should have 2 jobs" end it "be found by method all" do Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args.merge(name: "Test2")) Sidekiq::Cron::Job.create(@args.merge(name: "Test3")) assert_equal Sidekiq::Cron::Job.all.size, 3, "Should have 3 jobs" assert Sidekiq::Cron::Job.all.all?{|j| j.is_a?(Sidekiq::Cron::Job)}, "All returned jobs should be Job class" end it "be found by method all - defect in set" do Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args.merge(name: "Test2")) Sidekiq::Cron::Job.create(@args.merge(name: "Test3")) Sidekiq.redis do |conn| conn.sadd Sidekiq::Cron::Job.jobs_key, "some_other_key" end assert_equal Sidekiq::Cron::Job.all.size, 3, "All have to return only valid 3 jobs" end it "be found by string name" do Sidekiq::Cron::Job.create(@args) assert Sidekiq::Cron::Job.find("Test") end it "be found by hash with key name" do Sidekiq::Cron::Job.create(@args) assert Sidekiq::Cron::Job.find(name: "Test"), "symbol keys keys" Sidekiq::Cron::Job.create(@args) assert Sidekiq::Cron::Job.find('name' => "Test"), "String keys" end end describe "destroy" do before do @args = { name: "Test", cron: "* * * * *", klass: "CronTestClass" } end it "create and then destroy by hash" do Sidekiq::Cron::Job.create(@args) assert_equal Sidekiq::Cron::Job.all.size, 1, "Should have 1 job" assert Sidekiq::Cron::Job.destroy(@args) assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 job after destroy" end it "return false on destroying nonexisting" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs" refute Sidekiq::Cron::Job.destroy("nonexisting") end it "return destroy by string name" do Sidekiq::Cron::Job.create(@args) assert Sidekiq::Cron::Job.destroy("Test") end it "return destroy by hash with key name" do Sidekiq::Cron::Job.create(@args) assert Sidekiq::Cron::Job.destroy(name: "Test"), "symbol keys keys" Sidekiq::Cron::Job.create(@args) assert Sidekiq::Cron::Job.destroy('name' => "Test"), "String keys" end end describe "destroy_removed_jobs" do before do args1 = { name: "WillBeErasedJob", cron: "* * * * *", klass: "CronTestClass" } Sidekiq::Cron::Job.create(args1) args2 = { name: "ContinueRemainingJob", cron: "* * * * *", klass: "CronTestClass" } Sidekiq::Cron::Job.create(args2) end it "be destroied removed job that not exists in args" do assert_equal Sidekiq::Cron::Job.destroy_removed_jobs(["ContinueRemainingJob"]), ["WillBeErasedJob"], "Should be destroyed WillBeErasedJob" end end describe "test of enque" do before do @args = { name: "Test", cron: "* * * * *", klass: "CronTestClass" } #first time is allways #after next cron time! @time = Time.now.utc + 120 end it "be allways false when status is disabled" do refute Sidekiq::Cron::Job.new(@args.merge(status: 'disabled')).should_enque? @time refute Sidekiq::Cron::Job.new(@args.merge(status: 'disabled')).should_enque? @time - 60 refute Sidekiq::Cron::Job.new(@args.merge(status: 'disabled')).should_enque? @time - 120 assert_equal Sidekiq::Queue.all.size, 0, "Sidekiq 0 queues" end it "be false for same times" do assert Sidekiq::Cron::Job.new(@args).should_enque?(@time), "First time - true" refute Sidekiq::Cron::Job.new(@args).should_enque? @time refute Sidekiq::Cron::Job.new(@args).should_enque? @time end it "be false for same times but true for next time" do assert Sidekiq::Cron::Job.new(@args).should_enque?(@time), "First time - true" refute Sidekiq::Cron::Job.new(@args).should_enque? @time assert Sidekiq::Cron::Job.new(@args).should_enque? @time + 135 refute Sidekiq::Cron::Job.new(@args).should_enque? @time + 135 assert Sidekiq::Cron::Job.new(@args).should_enque? @time + 235 refute Sidekiq::Cron::Job.new(@args).should_enque? @time + 235 #just for check refute Sidekiq::Cron::Job.new(@args).should_enque? @time refute Sidekiq::Cron::Job.new(@args).should_enque? @time + 135 refute Sidekiq::Cron::Job.new(@args).should_enque? @time + 235 end it "should not enqueue jobs that are past" do assert Sidekiq::Cron::Job.new(@args.merge(cron: "*/1 * * * *")).should_enque? @time refute Sidekiq::Cron::Job.new(@args.merge(cron: "0 1,13 * * *")).should_enque? @time end it 'doesnt skip enqueuing if job is resaved near next enqueue time' do job = Sidekiq::Cron::Job.new(@args) assert job.test_and_enque_for_time!(@time), "should enqueue" future_now = @time + 1 * 60 * 60 Time.stubs(:now).returns(future_now) # save uses Time.now.utc job.save assert Sidekiq::Cron::Job.new(@args).test_and_enque_for_time!(future_now + 30), "should enqueue" end it "remove old enque times + should be enqeued" do job = Sidekiq::Cron::Job.new(@args) assert_nil job.last_enqueue_time assert job.test_and_enque_for_time!(@time), "should enqueue" assert job.last_enqueue_time refute Sidekiq::Cron::Job.new(@args).test_and_enque_for_time!(@time), "should not enqueue" Sidekiq.redis do |conn| assert_equal conn.zcard(Sidekiq::Cron::Job.new(@args).send(:job_enqueued_key)), 1, "Should have one enqueued job" end assert_equal Sidekiq::Queue.all.first.size, 1, "Sidekiq queue 1 job in queue" # 20 hours after assert Sidekiq::Cron::Job.new(@args).test_and_enque_for_time! @time + 1 * 60 * 60 refute Sidekiq::Cron::Job.new(@args).test_and_enque_for_time! @time + 1 * 60 * 60 Sidekiq.redis do |conn| assert_equal conn.zcard(Sidekiq::Cron::Job.new(@args).send(:job_enqueued_key)), 2, "Should have two enqueued job" end assert_equal Sidekiq::Queue.all.first.size, 2, "Sidekiq queue 2 jobs in queue" # 26 hour after assert Sidekiq::Cron::Job.new(@args).test_and_enque_for_time! @time + 26 * 60 * 60 refute Sidekiq::Cron::Job.new(@args).test_and_enque_for_time! @time + 26 * 60 * 60 Sidekiq.redis do |conn| assert_equal conn.zcard(Sidekiq::Cron::Job.new(@args).send(:job_enqueued_key)), 1, "Should have one enqueued job - old jobs should be deleted" end assert_equal Sidekiq::Queue.all.first.size, 3, "Sidekiq queue 3 jobs in queue" end end describe "load" do describe "from hash" do before do @jobs_hash = { 'name_of_job' => { 'class' => 'MyClass', 'cron' => '1 * * * *', 'args' => '(OPTIONAL) [Array or Hash]' }, 'My super iber cool job' => { 'class' => 'SecondClass', 'cron' => '*/5 * * * *' } } end it "create new jobs and update old one with same settings" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" out = Sidekiq::Cron::Job.load_from_hash @jobs_hash assert_equal out.size, 0, "should have no errors" assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 jobs after load" end it "return errors on loaded jobs" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" #set something bag to hash @jobs_hash['name_of_job']['cron'] = "bad cron" out = Sidekiq::Cron::Job.load_from_hash @jobs_hash assert_equal 1, out.size, "should have 1 error" assert_equal ({"name_of_job"=>["'cron' -> bad cron: not a valid cronline : 'bad cron'"]}), out assert_equal 1, Sidekiq::Cron::Job.all.size, "Should have only 1 job after load" end it "create new jobs and then destroy them all" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" out = Sidekiq::Cron::Job.load_from_hash @jobs_hash assert_equal out.size, 0, "should have no errors" assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 jobs after load" Sidekiq::Cron::Job.destroy_all! assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs after destroy all" end it "create new jobs and update old one with same settings with load_from_hash!" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" out = Sidekiq::Cron::Job.load_from_hash! @jobs_hash assert_equal out.size, 0, "should have no errors" assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 jobs after load" end end describe "from array" do before do @jobs_array = [ { 'name' => 'name_of_job', 'class' => 'MyClass', 'cron' => '1 * * * *', 'args' => '(OPTIONAL) [Array or Hash]' }, { 'name' => 'Cool Job for Second Class', 'class' => 'SecondClass', 'cron' => '*/5 * * * *' } ] end it "create new jobs and update old one with same settings" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" out = Sidekiq::Cron::Job.load_from_array @jobs_array assert_equal out.size, 0, "should have 0 error" assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 jobs after load" end it "create new jobs and update old one with same settings with load_from_array" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" out = Sidekiq::Cron::Job.load_from_array! @jobs_array assert_equal out.size, 0, "should have 0 error" assert_equal Sidekiq::Cron::Job.all.size, 2, "Should have 2 jobs after load" end end describe "from array with queue_name" do before do @jobs_array = [ { 'name' => 'name_of_job', 'class' => 'CronTestClassWithQueue', 'cron' => '1 * * * *', 'args' => '(OPTIONAL) [Array or Hash]', 'queue' => 'from_array' } ] end it "create new jobs and update old one with same settings" do assert_equal Sidekiq::Cron::Job.all.size, 0, "Should have 0 jobs before load" out = Sidekiq::Cron::Job.load_from_array @jobs_array assert_equal out.size, 0, "should have 0 error" assert_equal Sidekiq::Cron::Job.all.size, 1, "Should have 2 jobs after load" payload = { "retry" => false, "backtrace"=>true, "queue" => "from_array", "class" => "CronTestClassWithQueue", "args" => ['(OPTIONAL) [Array or Hash]'] } assert_equal Sidekiq::Cron::Job.all.first.sidekiq_worker_message, payload end end end end sidekiq-cron-0.6.3/test/unit/poller_test.rb0000644000004100000410000001035213124502443020756 0ustar www-datawww-data# -*- encoding : utf-8 -*- require './test/test_helper' describe 'Cron Poller' do before do Sidekiq.redis = REDIS Sidekiq.redis do |conn| conn.flushdb end #clear all previous saved data from redis Sidekiq.redis do |conn| conn.keys("cron_job*").each do |key| conn.del(key) end end @args = { name: "Test", cron: "*/2 * * * *", klass: "CronTestClass" } @args2 = @args.merge(name: 'with_queue', klass: 'CronTestClassWithQueue', cron: "*/10 * * * *") @poller = Sidekiq::Cron::Poller.new end it 'not enqueue any job - new jobs' do now = Time.now.utc enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 5, 1) Time.stubs(:now).returns(enqueued_time) #new jobs! Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args2) @poller.enqueue Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default") assert_equal 0, conn.llen("queue:super") end #30 seconds after! enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 5, 30) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default") assert_equal 0, conn.llen("queue:super") end end it 'should enqueue only job with cron */2' do now = Time.now.utc enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 5, 1) Time.stubs(:now).returns(enqueued_time) #new jobs! Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args2) @poller.enqueue Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default") assert_equal 0, conn.llen("queue:super") end enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 6, 1) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default") assert_equal 0, conn.llen("queue:super") end end it 'should enqueue both jobs' do now = Time.now.utc enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 8, 1) Time.stubs(:now).returns(enqueued_time) #new jobs! Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args2) @poller.enqueue Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default") assert_equal 0, conn.llen("queue:super") end enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 10, 5) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default") assert_equal 1, conn.llen("queue:super") end end it 'should enqueue both jobs but only one time each' do now = Time.now.utc enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 8, 1) Time.stubs(:now).returns(enqueued_time) #new jobs! Sidekiq::Cron::Job.create(@args) Sidekiq::Cron::Job.create(@args2) @poller.enqueue Sidekiq.redis do |conn| assert_equal 0, conn.llen("queue:default") assert_equal 0, conn.llen("queue:super") end enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 20, 1) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default") assert_equal 1, conn.llen("queue:super") end enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 20, 2) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default") assert_equal 1, conn.llen("queue:super") end enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 20, 20) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default") assert_equal 1, conn.llen("queue:super") end enqueued_time = Time.new(now.year, now.month, now.day, now.hour + 1, 20, 50) Time.stubs(:now).returns(enqueued_time) @poller.enqueue Sidekiq.redis do |conn| assert_equal 1, conn.llen("queue:default") assert_equal 1, conn.llen("queue:super") end end end sidekiq-cron-0.6.3/VERSION0000644000004100000410000000000513124502443015201 0ustar www-datawww-data0.6.3sidekiq-cron-0.6.3/README.md0000644000004100000410000002376313124502443015430 0ustar www-datawww-dataSidekiq-Cron [![Gem Version](https://badge.fury.io/rb/sidekiq-cron.svg)](http://badge.fury.io/rb/sidekiq-cron) [![Build Status](https://travis-ci.org/ondrejbartas/sidekiq-cron.svg?branch=master)](https://travis-ci.org/ondrejbartas/sidekiq-cron) [![Coverage Status](https://coveralls.io/repos/ondrejbartas/sidekiq-cron/badge.svg?branch=master)](https://coveralls.io/r/ondrejbartas/sidekiq-cron?branch=master) [![Dependency Status](https://dependencyci.com/github/ondrejbartas/sidekiq-cron/badge)](https://dependencyci.com/github/ondrejbartas/sidekiq-cron) ================================================================================================================================================================================================================================================================================================================================================================================================================================================ [![Join the chat at https://gitter.im/ondrejbartas/sidekiq-cron](https://badges.gitter.im/ondrejbartas/sidekiq-cron.svg)](https://gitter.im/ondrejbartas/sidekiq-cron?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [Introduction video about Sidekiq-Cron by Drifting Ruby](https://www.driftingruby.com/episodes/periodic-tasks-with-sidekiq-cron) A scheduling add-on for [Sidekiq](http://sidekiq.org). Runs a thread alongside Sidekiq workers to schedule jobs at specified times (using cron notation `* * * * *` parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler), more about [cron notation](http://www.nncron.ru/help/EN/working/cron-format.htm). Checks for new jobs to schedule every 10 seconds and doesn't schedule the same job multiple times when more than one Sidekiq worker is running. Scheduling jobs are added only when at least one Sidekiq process is running. If you want to know how scheduling work, check out [under the hood](#under-the-hood) Works with ActiveJob (Rails 4.2+) You don't need Sidekiq PRO, you can use this gem with plain __Sidekiq__. Requirements ----------------- - Redis 2.8 or greater is required. (Redis 3.0.3 or greater is recommended for large scale use) - Sidekiq 5, or 4, or 3 and greater is required (for Sidekiq < 4 use version sidekiq-cron 0.3.1) Change Log ---------- before upgrading to new version, please read: [Change Log](https://github.com/ondrejbartas/sidekiq-cron/blob/master/Changes.md) Installation ------------ $ gem install sidekiq-cron or add to your `Gemfile` gem "sidekiq-cron", "~> 0.4.0" Getting Started ----------------- If you are not using Rails, you need to add `require 'sidekiq-cron'` somewhere after `require 'sidekiq'`. _Job properties_: ```ruby { 'name' => 'name_of_job', #must be uniq! 'cron' => '1 * * * *', # execute at 1 minute of every hour, ex: 12:01, 13:01, 14:01, 15:01...etc(HH:MM) 'class' => 'MyClass', #OPTIONAL 'queue' => 'name of queue', 'args' => '[Array or Hash] of arguments which will be passed to perform method', 'active_job' => true, # enqueue job through rails 4.2+ active job interface 'queue_name_prefix' => 'prefix', # rails 4.2+ active job queue with prefix 'queue_name_delimiter' => '.' # rails 4.2+ active job queue with custom delimiter } ``` ### Time, cron and sidekiq-cron sidekiq-cron uses [rufus-scheduler](https://github.com/jmettraux/rufus-scheduler) to parse the cronline. By default, the timezone this is evaluated against UTC. If you want to have your jobs enqueued based on a different time zone you can specify a timezone in the cronline, like this `'0 22 * * 1-5 America/Chicago'`. See [rufus-scheduler documentation](https://github.com/jmettraux/rufus-scheduler#a-note-about-timezones) for more information. ### What objects/classes can be scheduled #### Sidekiq Worker In this example, we are using `HardWorker` which looks like: ```ruby class HardWorker include Sidekiq::Worker def perform(*args) # do something end end ``` #### Active Job Worker You can schedule: `ExampleJob` which looks like: ```ruby class ExampleJob < ActiveJob::Base queue_as :default def perform(*args) # Do something end end ``` #### Adding Cron job: ```ruby class HardWorker include Sidekiq::Worker def perform(name, count) # do something end end Sidekiq::Cron::Job.create(name: 'Hard worker - every 5min', cron: '*/5 * * * *', class: 'HardWorker') # execute at every 5 minutes, ex: 12:05, 12:10, 12:15...etc # => true ``` `create` method will return only true/false if job was saved or not. ```ruby job = Sidekiq::Cron::Job.new(name: 'Hard worker - every 5min', cron: '*/5 * * * *', class: 'HardWorker') if job.valid? job.save else puts job.errors end #or simple unless job.save puts job.errors #will return array of errors end ``` Load more jobs from hash: ```ruby hash = { 'name_of_job' => { 'class' => 'MyClass', 'cron' => '1 * * * *', 'args' => '(OPTIONAL) [Array or Hash]' }, 'My super iber cool job' => { 'class' => 'SecondClass', 'cron' => '*/5 * * * *' } } Sidekiq::Cron::Job.load_from_hash hash ``` Load more jobs from array: ```ruby array = [ { 'name' => 'name_of_job', 'class' => 'MyClass', 'cron' => '1 * * * *', 'args' => '(OPTIONAL) [Array or Hash]' }, { 'name' => 'Cool Job for Second Class', 'class' => 'SecondClass', 'cron' => '*/5 * * * *' } ] Sidekiq::Cron::Job.load_from_array array ``` Bang-suffixed methods will remove jobs that are not present in the given hash/array, update jobs that have the same names, and create new ones when the names are previously unknown. ```ruby Sidekiq::Cron::Job#load_from_hash! hash Sidekiq::Cron::Job#load_from_array! array ``` or from YML (same notation as Resque-scheduler) ```yaml #config/schedule.yml my_first_job: cron: "*/5 * * * *" class: "HardWorker" queue: hard_worker second_job: cron: "*/30 * * * *" # execute at every 30 minutes class: "HardWorker" queue: hard_worker_long args: hard: "stuff" ``` ```ruby #initializers/sidekiq.rb schedule_file = "config/schedule.yml" if File.exists?(schedule_file) && Sidekiq.server? Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) end ``` or you can use for loading jobs from yml file [sidekiq-cron-tasks](https://github.com/coverhound/sidekiq-cron-tasks) which will add rake task `bundle exec rake sidekiq_cron:load` to your rails application. #### Finding jobs ```ruby #return array of all jobs Sidekiq::Cron::Job.all #return one job by its unique name - case sensitive Sidekiq::Cron::Job.find "Job Name" #return one job by its unique name - you can use hash with 'name' key Sidekiq::Cron::Job.find name: "Job Name" #if job can't be found nil is returned ``` #### Destroy jobs: ```ruby #destroys all jobs Sidekiq::Cron::Job.destroy_all! #destroy job by its name Sidekiq::Cron::Job.destroy "Job Name" #destroy found job Sidekiq::Cron::Job.find('Job name').destroy ``` #### Work with job: ```ruby job = Sidekiq::Cron::Job.find('Job name') #disable cron scheduling job.disable! #enable cron scheduling job.enable! #get status of job: job.status # => enabled/disabled #enqueue job right now! job.enque! ``` How to start scheduling? Just start Sidekiq workers by running: sidekiq ### Web UI for Cron Jobs If you are using Sidekiq's web UI and you would like to add cron jobs too to this web UI, add `require 'sidekiq/cron/web'` after `require 'sidekiq/web'`. With this, you will get: ![Web UI](https://github.com/ondrejbartas/sidekiq-cron/raw/master/examples/web-cron-ui.png) ### Forking Processes If you're using a forking web server like Unicorn you may run into an issue where the Redis connection is used before the process forks, causing the following exception Redis::InheritedError: Tried to use a connection from a child process without reconnecting. You need to reconnect to Redis after forking. to occcur. To avoid this, wrap your job creation in the call to `Sidekiq.configure_server`: ```ruby Sidekiq.configure_server do |config| schedule_file = "config/schedule.yml" if File.exists?(schedule_file) Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) end end ``` Note that this API is only available in Sidekiq 3.x.x. ## Under the hood When you start the Sidekiq process, it starts one thread with `Sidekiq::Poller` instance, which perform the adding of scheduled jobs to queues, retries etc. Sidekiq-Cron adds itself into this start procedure and starts another thread with `Sidekiq::Cron::Poller` which checks all enabled Sidekiq cron jobs every 10 seconds, if they should be added to queue (their cronline matches time of check). Sidekiq-Cron is checking jobs to be enqueued every 30s by default, you can change it by setting: ``` Sidekiq.options[:poll_interval] = 10 ``` ## Thanks to * [@7korobi](https://github.com/7korobi) * [@antulik](https://github.com/antulik) * [@felixbuenemann](https://github.com/felixbuenemann) * [@gstark](https://github.com/gstark) * [@RajRoR](https://github.com/RajRoR) * [@romeuhcf](https://github.com/romeuhcf) * [@siruguri](https://github.com/siruguri) * [@Soliah](https://github.com/Soliah) * [@stephankaag](https://github.com/stephankaag) * [@sue445](https://github.com/sue445) * [@sylg](https://github.com/sylg) * [@tmeinlschmidt](https://github.com/tmeinlschmidt) * [@zerobearing2](https://github.com/zerobearing2) ## Contributing to sidekiq-cron * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet. * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it. * Fork the project. * Start a feature/bugfix branch. * Commit and push until you are happy with your contribution. * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. ## Copyright Copyright (c) 2013 Ondrej Bartas. See LICENSE.txt for further details. sidekiq-cron-0.6.3/.document0000644000004100000410000000006713124502443015760 0ustar www-datawww-datalib/**/*.rb bin/* - features/**/*.feature LICENSE.txt sidekiq-cron-0.6.3/config.ru0000644000004100000410000000040613124502443015753 0ustar www-datawww-datarequire 'sidekiq' Sidekiq.configure_client do |config| config.redis = { :size => 1 } end require 'sidekiq/web' $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) $LOAD_PATH.unshift(File.dirname(__FILE__)) require 'sidekiq-cron' run Sidekiq::Web