From d002c703d5014cd77dfc8408bc0991156399c157 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 9 Jan 2026 14:39:28 -0700 Subject: [PATCH] Fixed a number of sync issues I noticed --- .../UserInterfaceState.xcuserstate | Bin 310253 -> 310210 bytes ios/Ascently/Models/BackupFormat.swift | 107 +- ios/Ascently/Models/DeltaSyncFormat.swift | 2 - .../Services/Sync/ServerSyncProvider.swift | 1063 +++++------------ ios/Ascently/Views/SettingsView.swift | 58 +- sync/main.go | 202 +--- 6 files changed, 478 insertions(+), 954 deletions(-) diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index d6362b2037c4ea855a3069d34718247415cf7955..424d3b0e928706eb96040247485fc099bec5baa7 100644 GIT binary patch delta 71878 zcmZ772V4}_-p27WGq`pIu{Xemq9~$*T|u_f6a^8aC@6@Ez3YOp_kN5~W7KF8d+#+G zqsCrh@4Xv)e}BUo@4fH)e!lB)cIG_iod211He4USDYExXk<~jYI0gm}4W1Z0DR^@5 zl;Eks(}JgmoDDf2aw+6$$c>QOA@@QahCB&*9`Z8eP00I@Pex(Xj0R&~V*z7fV-aI9 zV@YEfV|k;Ov69i-=wtLX`WfpO1B?xfjg8HW!A7IeVze8>jV+C>jWNb{#`eY}V@G2r zW4f`cG1J(~*w;9~VH|86X3RE@Fpf5kGfp&4F-|wmGJbEIYy8o;(D<|Q7vnPHO5+;i zdgCVJR^txiZsYI9Ka2;Be;JP%Pa4k{&lxWouNbczZyE0z9~d7SpBY~mUmM>UKN`Q7 zR8uZf9#ei(A(NY_sHudhw5goQ(^Sz^#Z=8y!&J-UZ>nc<)HgLUH8lm9LQH0p%@k&e zG)0-BO|hnUQ=+MZDbWOItSv$>18o4JR%x4EBrpm~VdVIFQCX&z%9 zZ=PhHYMx=1<~inh<^|?O<|XD|&CAWJ%xle#4d%_}ZRVZkJ?4Gp1Li~KBj)4gQ|7bg z^X5zDtL7W#+va=bhvp~d=jNB@H|F=|PZnX(ECx$nO94w^OA$*kOG!%^OL>czrIN+l z;$!i(_*v>$0xS(IjV;YA!4{*%VzFDoEiEmrEismMmiCq;OGis5OS+}2CDYQ&Vd-lb zU>R%~X34gUu#C2hvrM#1u}rtjvV3ouYx&W#(DJk87t1osO3NC{ddnuuR?7~{Zp-hM zKP(3=e_4)MPFl`b&RH&6u2`;HZdvYH9#|e*o>^X4UR&N-K3cw5RckJ59&3JUA*-9U zsI`Q(w6&bo(^}D5#ahi;!&=Mg@VC~p*0(mYHnj#>L#$@2%^GHnv_@H@t+Cd4YofJ- zHPxDC&9HX2_O$k~_O}kQ4z*@kzq5|AjtX9r>j~>=>)+N3*2~sw)|=Kl*8A2+)~62ZKh{^)x7H8V z&o;&8V#{sIXDeuPwYl4h+e+EW+B|F(Y?WOTplyiFVH<86X&Yl3Z<}PBYMWt`wmG(W zwgt9Dwk5V-ZOd(rRkpRZ4Ytj;ZML1ZJ+^(e1GYo9BevtVQ?|3V^R`R2tF{}q+qQeQ zhqfoS=eC!&H@5e-Pj+F~>;`*YdjWf4dl7pvdr5m4dwIK;y^`JA?qm10``PQ*1MCg# zjqT0s!FHqFVz=AF?Je!C?J@Rt_V)H9dq;aGd%C@=zdh65%ih;Mz&_YM%${u@VIOTD zXP;=FVxMlGW&hqj&;FBrnSHr^g?*)ct$mYyvwh3DPTq}673tWaN7wY;ef<1RE2`#_ z%dmFCbdOq1V#l2AT6$LED%Z%|g^Cm}Rkoa0#me43)qQLE*A1-SsByEP5R=s&7T&T| zo9MRf;uDfPq@;FE@7gW1XP>_P2MigOHGIUVG2{dc|ZQNXz`Mzzb;#` zYRz<0P;jn;pL(=-GJRr@mqR~#?W&y2lee_n!u(|tT?cAy)jG9z&U|h9=%U;fGuqZMK`AN5l=MFOC3NhcH>Tu54-bBd{dZKu z4{&xIgVGnX{kOELv?el&r0D}%ly|h(2i1|dq@0uZp;Ix|jUUp!T~V9XF0^K}GxbXD zmfAnFSBAbWY5HiobE)2ck8fu;ptYA?tlshZY6o@flin}A&%m5>c&nd9U+jN>atw4^ z?BOwJaNDTVUj5QLruJ?fHL!a}-W{4#p+nlDgZsJ2p;xFO&Dq~#f@uD(Bt@x4^T@Bw z{l?~{d2|5HgJ>R`(|kx-v$h`1N6 zGF_j*jOeh;l+>I(FzZ{cjdy438$_SOEPV-nV86G}f6yk=H>LOFYU^2J;l+wTF=%4R=WMPj-S^vtA!i4 zEZnrYqWJoM6?c0&j}~s-Hoc?K%du1}7i+{eu}d5j$HWP7R@@d(#53_qyi;5hgOXn< zsuWksDCHGDrJmAIX{wkNs}ionDDg^y(oyNE^i=vPS;|OdoH9w7uFO?_QWh(ll^x0- z%3y`tV!@2C&eXX*>}t)^%OEw5Ho zE2Wj!ytFD>Ev>FrUo&bpEkcXZ+G_2!4qArRL+h&z)4tP2YZJ7Y+8k}Zwn%d<*Vbqo zw5{6j+Mn7X?U;5>yR6;N?rTr97up-`vy0|Zz{Sm_m`iCF50?rq-Yzv<{9Nj}G<9j= zVs;62Y3b6&rJYNnOGlS9m!2;DTn4%fa~bV2!DWieOc&`g*JZxTPcDmHR=8|*+2XR- zx$vs;wG`X+KMQ>Qfj?Xhughx=$W^gkkY59%DX5-raBv-?Z{3i(M&J5%g6sME8(YvC z926W>XI+QT9eI8E^j?(flqe_)iNab*t&FI#kQFGM^(pJ~LanTFDeFtt_gQtL^-qG# zUNXL?R`$E@(WARZE1SAUtH~M4KzV$Ry3nP6w)+5iwWrom{wy6N#=wVvV~++8ab z+#}n~C+kzTTlGcaKydHiK3Pq&-D+gJ`DWFPiR_ixCADK8*?gaBwhdA~{uDeo+pT8S zZ(oNKJS=#msIes25u6pA9XvevyWkPoZnd)A{IcC@XS?}xt7f~^T@pMhc(k%j|9`D< zwp+by_kQxkKJ|9)8Ops?Zff&QyY{QYT-1=$o0=a|cNhv~yZ6mnaZ(g!w`GT%lX*_4 zHI$38-U+pWaz(a1p;l6^%b_RK>dGzo>j~9QxhpT6Q0poWWZ{!)1Ld)_o}}@a9CT7` zYIu<~IMKDbbevSn$%`k|;>v6J&q=k4@=lgI#UCGK^HZw7@3)0+a78qPs{IjiQC z`_D4jT6%l&S%0fuiodMzw;HC@)5q3GsV|rRO${5#8-J_Kl%}%6In}BJ$*$+rhDwNB zdQJ^DnDzJox%`}3OZuEw3oAC+@H~Bn$;9(&H6>F2qpW*GOt`Vt*9A95>6es|*UvMd zW9QWZO0>Lmo|sr!2i^4 zGGxPxs<+Zz|D&`Va#1a)^pxW+vX*`1#*3<#p}*c+DcS6zTC8AXP;1M-gBT>gTx1qQ zWy4FXf0j(Xr1~h|$qAR}dz9RHNv&)ct6%CVr(ROs<;P1b`~+F#GKHTkTU=%}r^x}A zsq9R-OmCbmFX?~$APZh$kn?5G6-xAz?0JR$7R&4JRBw6c3JbE-d0gfy$G^#I4`}at zRjsV7kgKk7yydFuuB?_vuBt7Rb<*vcYEd@I=C^sL&^xt|oP167P`2o&HdMAtx0|%P zT~|vgyX4Uu9Jjo#mQnUPd!2EeUiVAad$eD_u6ine>fL$EYByMw!}`?f%QdgmO0v%l z*7&HLdP8ljoY4CTR!-|(H&gzWEpMt($_2UVv06z!AVUbobW$~EUycHC0) z%l^02^2$wpq}AlETP**bZ^wCMx!df)`+7rb<&lj1hjVt`X0tt&*KVt^%0JFQCf`v# z4X^ZpHI^stsFmgNJ52ViGyK&Z!av9b_qf(_mzjNbhR?i9yW%{)qaVBIotc%~&OROY zn0P+@vT&uKe0oo9qqyon`N+)sY*KgUY68X=P( zsWoJS$7*#$p!3?ChFOm(WJ7(rt&}D*>6KoqCv?%=dF*jZ%`JyKQOhYUoDF;QH%-oY z7JN#s)j6m(PZ?CG-f_6Tj+Nwsr)<**=Q?uiCJR1OOB!0~X9PRXhDxEuhkI4 z4Cm}~Zh)(=nZ4BK<1KyPsJ@0d`kPkg-BIg}>Qyj}F9TmcI85`L%XIlQ6Tk6>DJ^iG zoinA%Z~2>Kkv=7F`PW-DpZxAE#aN=BQCDB;3VLg4!>@XS%^BeOPOYgd*Y5;x+36jX zTIDS1?st@Qt-chI$_D3jV&5~J&CcU_`tde>=G@-znZQoxuBi2aRoEk2f8g(teRAjr zmi2&Ks{eFI9{<3Nc|@-GqLz}yKJtNfTt5AzmX+p@s=MKoKBihy`KT6^$3CeBIp`yM z?W}Zs;_RG;O&`?~%6WP0qgvN+Nk6-`^Xzh;xL2;uJffA7@t@cUH}q=)l-qKsLKByI zDEIWBTFOJ|@tGMuadw*VSuJOHu6I-}r=t>~A&KD;iIG<0zia(cf78>Ma^$mG)bK`c z@N+hN_{>b-OV2MX`6n6nMfJ&T7W(^DobONjqLx-PeYQ0fgS_-bZDPpl?C-gvm7Up0 zD_|&~|6Dca^qI*#=oFqgObb_vFqKAzV)}bEoL$8$n!9^=P?$05>&VO{|My)_rHnq? z`iAoV=}{gdM)7hkcP>>cW~ijUS<%^LT~#ZgcsrLURnVZ_y@6b;nJLopIXX6x#+O9+?Y%A zR$}CpTv|P)oh)k5e3kYx*r53+NwS+ktFLsFOAJ~~rIS3PH>S_Zt@$ZkW$oOWw;@xH zttpf85ZfoW=4t4qH&k>stj^7<^!;{*r+k=OD{dI@?Tk{=Gmln887%AMVFbfuTppsc z<=8x09c6^vkw>emjF$i8(P|mS>DPG6&|I3QH0RX{D-&f*Ugj`Gj?1gnP^QaGd9}*Q zEO|MvR$KXA7S2Zxb7hNs%;-lsFdw5{C>Q6Wzn|s#d|da7ERbKTr7V+;^D~!~vRi(7 zStIA<*R0BVc}xFeldM@lYoKhEX$4rb9rDKlS~X?2JXnBUewUvMaNQrWRzcR~piC%8 z&|h*!K`uBZj}~OfPs;p-G+)CRec)xKYay*@achV%ytTD;?7y4roc>;oocH9ALYgPf zsPhVGRd`0-SBUkvE*}+QjJIUj!gO@kxdP7%QSgkyl=gvi6ej$!Tv?d0Ka&>=Glmz^ z%~k8dGwKjmEsAGUezM4tZdwJyNB#O>neD1omlSDg3l!B-c%JQ9RI~9syS=Cu=X{xgOe@LrEEfmL$l_W7xvUr)y`tWIjG>Ai z8>*k!RK^!)-&E5ZjLs{6E3TE{L6;r_<)`AB{*3$Ym|SIg3C)!!-JCbql+Y>}>giWT ze4Bx*ELW18`u}ywppu%aq0xT@=qpm7O;qsLT3MRPDwaQ_5&1<*3qHIYYFb^R;0Q4LliN zEzLch^JH9IKe3!_RE8NR>L-3}=tBd~$T<St(2kXe@@mL^2p`982ad~Ue42wmt{lrm$%At zPYsfv%JFGmtDIKEFjPOMvaDHND=m}DaZZ-(TTZLa!o?|1m4t3 zt7}-Thxj=|e(+)m*U2?rT5}%5r#7U|Oiwnohlgg6Wh-#e7TL4{mD#ReRL^}Lwb)H>J-mIXNH|*8VsO3Dvr=nJrM{~w*WS0wtV9$i$< zugJD(Sc&?c(9dql^ZArYd@}zn|Ek0%$pz`*t$9hG%3N_-Hm}T(sLJhJx{6knr}8OPxb{kAEw{|5!m>Ql8$x+Bzg$HN;?bP01C&?# zPhZa(=B<_Dshn|qZP@M2PW+$;dOHK(dQu|d(>8#>#&ROPeFMW3U|kXvsL zl0B<2)uYw4!ZKeqt*jxRbJ(={%Ghdr@)y)|%E;wa*?yC&X=Mzq`Wd0$t_v`@>kTo| ztvYq}_hIVA^#-%EVQ6)(`_V`9;IW=*m64u4d;~qx8_LQ8)wLEp+ecRCcWV{&i%jxF z4XwUBRh@;dEL%3AephR7PZq1e$79Y@eiQwS8Zx~Gw?}pTl19!;cGuAI^Qg~;%DE)K zP+Na9P$v0ur`GpnW7d^%zFM3TC^z}CtPSNmU#%lg`R!|Joq51t-HbcsYEAyeXyM!r z5&qPJhN7}*Ek3JE&aVG$Ei4Dr;%`!`9uh8t{I$CBWG%*+^U!aRQvYo<(C1qIisyUnDWxK0VA4Nn-aw7 zXG9wkoHNSl+t;5vH(6fuXSMqHb04P2ok9Ozw9lVU-p=})5j^?()zMlj-DWl6%pG;K zl7=37H$nQC>&QZN**LwwO`?u$UzeTQPak4i{XZ$S__xa<^|+&kNZ)!|d!G5}`Rfc0 z)MKL!mr0G8o?8I1Bc*SEmcY|LLk^Io0<}W&cmQ)4?;PK=Kzj5DWWba3`=Gwe4Ak5W zQ~zs7+XAWm4Cm!}>a)gDf3vM~yvg3hx{HJa~jj7ufJU3F1b7;`~tM-uc?u&p}M~ zxxOQ!3^`vgEYhn5cZ?;Nv)|~ujK3j`+_;N^xu@RisX1o^NTmh)@Y8>t5z|8R%5BT} z(vfpY6&YsaFZY!+^UZ?|+{$3kuMcu2#DnW4u+iX8m;s$D#=ij7GNl&_xZJ9ISopAXPu-nu*G0Q*^w-tKQ+>|&kA(kytELhxpV+iizI7ypYfa=-J9n7H zIhlWh-Q*j)R@z|KTVuXm?j~bG8DzNL`k$d!HMGlKBo43mDw52M{_5=v58EKppxk_J3>q53(d_4t(}r7 zn?~}On)5ZLvGi-HRgilk*(f<*V;blURi#@??(zY$dQ0xq!TKL{<))T=whfaPTXN%P zORrYkk|ShhD>lJsxvCW(lH;UH6n~jdltEGa4L?O@Me)!vUG9nEA!wG&-&%_|e6P=- ziF9qPH7WGp3mu z)M!3wd&OuK4V(0aR?dbKF+5do)f-HDL!k6;tCcY9&>I@$oKe8sBIMsG?3Pp8YOVOr zl$rQ%f=4V5(SOM1u`J+0{q08bR4kv{f5~g{oRhzu*3fWF?~P3q&4_5oFMGD*20p1z zG(tI}&n(iA^9?G(dCsaho>+Rv@z=yfeJ(Be8pSNz@HLA5J!Q_@1LFBwb4zazl858@ z?7pi9wlh4?v#dG&%xX`?hqh-!J=PoB{-^PW1fIIDwP&Kw^mn5E-#exRK6PK{??nF3 zJ7xGbMo6&KuP@RnDE!;Pywgv1jy@+fCm<=2zkEOHYZb?D6<8rpIWLLlj?alKxvF0g zn$x}BT2jU(v4L~Rp-JrNJo0)H+b+MXmCP+%NDfZsBf?E@EG{o4^XODmzq+wp-hmIA zCLO3i37ODAv+&JqX$PK_%E^Zv_zw@B`dNW8JC)?{jx2da*}bD?c77kL<7_XR!h_e% zj{NPK^QEljx7Ol@8v5ma&W5%rd?416JyWzGgTLNh!`Xf=g{7~jcUxH&N#*9MufN&A z`DVLRrqoCdN#!xMsXoBs@@gty*@K+tKS|{Q-O`CVhRAlEa=wJk>7<$X5_Y|l7HkO9 zd#WlEJFyvTo%s}qlr6)T-#jwWbh=|Q-3>| zUr*3=N9V66Vw_h$&EQ)_Y*)T44EnD=H+QAaq5625%aYyrXw1^zjB;MmyBk&dPVcI@ zJl&0(aFqULOXr*QyVKQJy}{;eklne3CdgIYwRFB}<*Zz-OwHdgO+P1Cme1si*3?XT zoGI64YE@#Sa&JM_im$(jv(K^rAZjdFkX85JhWYj%|F>bGeTk^C$iB$F*#2|2`=D(1 z!P)LZ7TK5Df3g3X?LIWy{kv@U5m|MmTQ6$HZwJfBq#jy)}tH)-FgT*`JIuK#jUUhAb5+cco3 zwoA#yuQNxylZ|_8rHa^}>SxUO|G&PJ6MJfTHcjoVx#p3DhiKL0mBHG{TzhAkqc+G5 zL$vOdtAv#bD;riWtbCY0MOSb#FZ1?xs?jN$-{t&P zeTp(onW4;9=J4C~`N{%iqjFw(sk~D@D4&!sszJ@G=2r`IOq50N7WPRE%pA_-<=oKT%Esz{`QOMj{o_cw0@*j-V`;0 z=et}7k4|}fTV{{cJaX3)ex9{Nt+tNg5T*|cVaj8;x)bqVao$L zxAE(KTOqLIwj%I=4+yalVk5*BfIjF42j*iD=+AZxCvXa9a0L(blOE$Kp5wC+c9zJ_ z64_ZIJOASkJ0-Bwxt%iDgTV=Q0_=ma1gpTn?fXH9{XQ7H{R2LMNrci@r~ynSl*xp$ zLZS2*>R?Gi(?N%!bQn4m3@UU4Mqv!bVLi^_I&R<=7NIKfeoPuMr( z9VC9ka%{jJ{DG6W3sxyYrT!5v95C_-mL`IwiJ;CA)H$L#tYA?d&~M@#ap%mgKy~=#!D6^Vg_bm zHs;_bEW#4}ie+FhE!hVxpW-bDYDrK_f?8EVbEJSWw4%ROl%N%pXmtqp@BokS1kb2{ zD@NYxHR!C>dwdiksu)V36xdKvY^W#?FpDT3Fq0@YP*iO+1))*g8Brk!1G9^YM*@=2 z4SkUf_C^%tisCbX-z_;r)I|=igJoNPLfl_?g|oY0$)KJw z%sQqEm~jlXjTwj`U;;72F#;3uGggDi#2mnJup%*ML3oTFd>eOhpZdo< z`nC;2C$MyF32RGOTf*8B);1f=s_lHRIoq;1+p>CXmtiw@VK4TBRcw03YPR(xf&0E3KYfbne+ha@2?#CPC;4Hi$m<5|Y| z1z-^I`)~k<$x8oy+Uxa9{zy${60h#R!frV>d6vaU`+cSptd_=ToaP7Gn+7HAS z(0%(qa0&F6PytjnfxA6{u_Z9J1jd$-0LGWl2P~i?VK@gPK_>~6H$h@PC~v}IP}+o* zpqdF(Ghrh(gRq2CxPxbSCqyEHPb>&mxPw6_mV+l4P$CPK=#2&lMwsvriP0Ryf*B<; zy2K0&z+eoML9;X-**Tnpd0?W66gP1RC~o3!*olLhXHcQ! z^>V7+f-gOa2Q-h3Mb|R-i*Y)Wa5Q zrgT6m($EDgM9Lr#pW*=PkTMAjC53UP{0P<~h4n~bJyO)Z*wkc|;w3wNH4A21&*ROesVQJt4@uo8^4^E&XskyZ$`zz#_> zqAl17X>^>HfgTu$QTQHn!HTERPue#831*qbEYp}}8k0<8l4%!k7fdki5uQ@UwC8w% z*LW*LdO_3x_e477PyY^do=)fKbe_H&zk_O~6Ov9yIw9$&KtMVH>5swcrGF8k3oF(o z7xExK=)X&8P~t9*+Pv^-(4_$yBM2>Ef(7Adj}90MYTo5Htil0Mwk`y9p-f#Ee3uWP zR4kInAUK1-jG`z3LNm(26BWP;J2Lz^sE0s=zziEg5et?*g8^hv+YB~VMt89M8NI;j zWef-P&7c$+(?KaRe#AnsY8h<8j5Sz~P1u6%*om{$KjS(FPw)os!RW~mT{RRyA-JLl zm`T^-sEd~9gNc}fd02o&SOS8(64Z4+4&exn<0P))4(`YE@(52s{ky&cOW2j$sT+gn zmKOweW7)c~Y~3oN3Vh)QR<9eY*NxTd)*N9-Kr&L~nz>pT$7l|zT(|L1=7OUG# zNPG{f*^M>m&bH_tg<)8UYeHle2JM;bmP{fu>wwtIV01($q=SXa%tSBr#Q-6seV*p! zpaPjxAafJ8Vh46%5BA{?9K>HZ1{Nvv49?*KE`h=Ipa4C*5QH>L0hR8t8Jyqaz7RcG zx1J2JrxA(hCq$8+4i08u7MN|%x$@IIKAP8YxEp)HIC`=Oz39JJO|ZVbm~1a5+G`AE zLW0YC&BbG|VS0T8_3EvF-P)UR_3i=|qc@AuoB8&p-`>ox_hB3rq7Mt%r>PL8J`}AF ztJsIZ_F>2L*#H))&lYSKqHhVX@_i|K->RsN$zT9|8GPT3*dnjb*SzySm%k`L672l-HtJ9}VZ4jAsh24IQ2u!fR4hlbb307b=SpUJS|6m3*m;nu@pTP`h@SnKP zh8t1`s$T&;~%RA%;%m=d`@++2O71n@VG-MkH8*&IIz+i_k z*dZ5i9XD|Y_u+Ub#83t>lmQHlL@ZeDp)B`MWHddW;2YCVa#S2LBj|b_6G0qiB1J_BQKboqcGgyk0|s5Cpm_5s$(PvqcH_j!5|zA z!odZQiT_| zaYlB=c(4^lvQQ&m3NgwZeqhx{HAXW8!3Gv%R2#$~7IDmBRC^A(fU=LG?4$aE35^CVY4cx*VyuuqHM!O(C__!HOB}NxT8I%WuA6*Gm5Frx9 z=xh!aVi`7r6&cNnj3#6>9go&Kd@sZp4X!ALlAyyeo~VGz@CG;4m=U1|;TSF6QGWEW*!Vu4AqXF*XlapgGhK`pQ?$JIkaG(mF&BNf9j8ME*`7~D7(f7}vq|BYLYRiNVID8P6& z)OhaY@yvTX^Bx}!Dm%UdGC_aiM~XNxo>`A)#^ags_<2}}#aIeD9?!y$---kH3&(I0 zf8#tZfvq^68+`nGAtn??5fqbKerAD#IShdb7O>kUgdq~G&>AsdY!j%9 z5EEIvi7fO)7J4F^apG7o=ZWjU&?a);#GUvZf8Ze4Cll`pF)2R_JgFoHEb=5a#H5O- z3Ko14i!-Sfnt}zI#DFF-kV);(9!cm7LML@Y5A?zhID#j5BgAAElt5|F|71VV-()5{ zIf~Df$!$4cW|I@p5ma+>8t8cPK#T&jnmhrMF%4|P$!xpHi@>&<{0m+RF@@EbLg19# z$cKVp!KYM)KiEA}>Z1{u*p%iDURdHO$>Xbg{k3krUEKI}{Ob1(R%J*Q4O_`4c z*a|8=<(&{yJwWGE+kovfl~tO`WTvu8Q(2*@eK7#^Kh^O;h-pK>45x9UO`D1tAYd8+ z)7Zn)nCUd?IgQ#)JB!<(e$yU;WuEpNFTrw8doRTFyr8zz3&9oKSksGvQco|9vS<#< z=$Ot5OlKLV@5Oa|7Geg=H-qJy!Sc;u`DSq83@=m!OF6?E)leNQ=Zq$x!x=4LfgRz9 z1hbyO%FUPu?wuKwYX;?-u>z~8|BSU9tjF(Qht4>K3%HDHxQPdNjAwX(S3=Az4+3UV zvY9O9%+Z(wf@UtkI?&%tR&6G$HuEB`fJx5016FXR{tKmh-By>b4 zWS~0;nbjL3!3LZ~NoMgWFl#A(1GAa68idZ;i2XQ#L*P?k)-jyGX)u^s=fFT_Id1WC z7x(cG*blQl;S10D3N++G5pWwx>L<&g0xF{_d|*ZwuyG~(Nis1x9HTJ~+&yv~>%^ zXS15K)4__)?gIuodyr@(W;5E^*%*O|m;rYGY&Og6pTTCC{To=?*}H|9yBGWM=caPG#Eob!08U1qBeEDf1R#ZV9)I%T|f+bl&(25_xvaDcP zR#1W!Ed5HBekDu4(tx%|MQ1RBm0htDhe6Ita#o%cViha0sy0iyil9{lt*Q^!ew744 zs|Z>(AMD~)pFq%Rf>ygA8Z6Ulf>sl>x(l}BAP8Db(CXtttfB5}YJs3NnZ&D&7fxM30hCk`oDzOP#)Dm&<27w_=7#L zVJZmPK+uNScz`z`XahkTJ`1st`+j44kh77TjVa)^+xR;i1Z^Z};~^n7l|?lWw27ci zei)9)AZQaon`Yr2UV)%Z1a0~##O83sfuPL({PLWZ+p%GLE8x0_Ew1PHpGCS?F4O4z-sIOLE8!1zE6l9#ZeIi?I37} z4~AeY2--o=jw!f-ryyqsIXhkpvD1P!(0|3Xlc1gPScz>QXeU8C_X@GAD7-+>E`oMd z#UP9ZLAwarH3`@77zFJiXxB?2cAF3df_5{Z-R-a(TR_fka&|j*3$dpNJV4MMg7#Fw z0E`4ddkESy0T*x=_wf)xRb0m{A@&8J1&lC*678FdU$G1;K#BINC=55agA(meLtl`ypPc=aX#Y`M z#AWKgpP>EJ=np?MK{Es)1hcRRKVvC=!$%k255w)4qm2U9)846Sd7;~9Mq5txsea?NJj>`p$FI_2an+dPT{N&hu9;B>Vcp` z1RZJ&_Q;_hK+Yj@4lM+G8}x(is_h%*?53Ac!v-8EX0vWv_~TKKa$Kr3O3?*?8gBd65?oCR0Bar2|DVB z;g}49juLcq7VhB{2s%p8(T_qL3r8FXI!4ejN_1>J_JEvY`0@rMv6OQJH!IZ4h* zK9Ekb*-wsl@Iuf@f=*7uEj$N7CkZorG(641&%QboQkXe+MH1EfIxi>i_pI9BjZQY{7OR&J{u#l!FH< zpa+J66+4#=Hru(AxQgqz2}*RnK8!HK3QBZ-K9*qxR)G>-aDf~3zu?Y6F;JrmUC*V*mY8=w)IpgGth*MFe?*XMEYBNpNrKH-ZHHxy8#8*Gvr z9g&L8phP#=BR3A>Fphu{-DHp4tcBXB14?vr3}!;&dr+dAPw)Zc-26iQZ&9OLt&t3Z zZV_~g8r|B810d)YLANN;ZBO`upxXr9rbM?#VLAx9P0(#hbo&wBfuP$2-4SSoM38fb zoI9Pc*}=ut4heC;EULjl(0zjL`(ZdHgP{8a-JgYfcm;y)6LkNh5D&r;2ZA0D z^q>RQV-E;=K+uCfg?LyR-XQ29IS*?h3ll-kLvkDsXK=v!JbVFy9uoBMy%3K=5etGI z5%eetYq1LiJtFAQA3{9lwtZX~1U)9`F`Mo2FpLL5j|qA_4Y%+d=u0ax%41ic{W#Xmy43_(i} z^pc>LZSfm6fuNTJz1%6pD_4{QL9YmURSEs@9msh_&Z}{_MEzgg=YXJB1igAD#Oq*0 zfS}g|y^h8&*Z_iF6ZCq!5N`^h3R{g1%f^j^l_sQpYos>$oWLhr?Th{$6#Iv`b5yD;W&-!Am|f8pY93qxgpFT=rci| z!?6G>K+tD`KCc(zivjK+=nFw#N~0?Vf}Ahpe96K|Tm?CfF9dzLBNR~=L1+OZESQ6( z_!Y~rQYeZ*K@^4?ilP&Gqc8en5dOk>T*PHu6N>7OW*|o;M>PqDqR!^vXAq_jrbk=aR8L4 zKv`5nb@;*$!!eoq7nsVybj-p%yuus2!$+YM3`ZQ=BM}|28auEHdq9Z_6-Px>hBqit zp&=NH@tBAyxPhm5ju#GIUJIo#yS;E5#2^;&V7C|E27(F`RCupYT-oieULeSoAlIs3 zx4Vu8L9PV3P6E5#^)U!?CCK%qP~1$20y%EvxV3|0IWJp4kQ+g6yM%9+ zqHMOJ{qP+KDoRk%akzy0AgCxoMV|?!STG_$P%(mvMPo76U>!DKvrvlXLkW~Z8I(s4 z3I?C8#VlD$8y!n+bx-5>%EF zmHiWEKu}qN%2J|oJ_tkuG(uBM!94tkpRgFOg;HKaF65^E<@0gCZZDsX40HoED!&tl zaRkRei99NyHtL`rD3QlFNPLeUFdxtG315WbslWy75zmfDMQ3!ucIxkWkOP7|3GzHH z6t4=X1%kW?@(REh%mhJR1bNK`A4pywK#&(f6;$wnR3RAzRUoKB8n)s9$f-b1g`+~L z=m}rca_~Y>#kv@U=^&^gK^5oV5#E8IiUjc_rBrH#L=aSoph}&v8T&y{C4wrk*(#Su zbr4jU303yT2uuYzmC32>V6#NDOI1UJ^N>EicTh;a01A?j&RF%zE ztu(wrP&I<8)kGF1f}m;yRht2B+iEXBP&I<8y%&m4C}Kg54>>+bSc_fQL;ZaS^7%t3 z)k~r>2&zs{^%@w4@gS%=LDi?>7M_Ek>I7APE0h{G#DJh01l35uYU}_(HJDJ1eM0dq zj*1}1mmFUo2QNb~76kbcaS_w)hR3um#(&Qz*4uQ4SvP zLM8OWcNmG$7>7%6+~?&X9^;u%{DKhyg8T^bi^eb50D}Ao^4l(y+J#UC1l1;}b_Mi7 zHVCRsQ0+0efV&{4HaWGQ2*tlS!a$D0pCJF%Sb}vR$e$qptwO0&0Hr`s9fIn3q8C`T zIt0}rsLm*y!)*{$hoCx-gi^OD>>#KvL3LYUG1h>by5!W|O#SQS6H2`jAgCTe^~$3M zhJv7a1l1dXv$zR@>Je1$flvY(!wP}|2nvYALaYKo0R#nX6iQ$o6azVdQ&*3mVqQpul@VsoxN05LBO_`r%lB6(FcSLG{-QrGWwNAgBRB4N4;oebFBS zF$70(5tre(%F7L*G^~ptw15#7%)wIpie*?SltuytQ5bG0icaVaf*KLjXb}Fy8T^g& zxFnRuHPHx7&6xC{1!BFY==hlF$vA=!rh~1E)Yx6M~vt5K2>S z+olabP*Z}MHV3zD(;q=jQ*xRv0k>_lT-3iALCpwi#%62AecLPp1T`b58Jn%yKAZqS z%?N77W^3+)KoHcNpyq70<`eKee!x6XqUQhLi%^0TXrM$v+_pif=!|p+FI}+{hj9eQ za8fA2+_%BCQ3v%OxF>q)4In6-pzu*Rhua`1oS^VWLWyV!I|zy(D1yxvu^4MWPy|5{Y_`aJC;@^Z z$%$mIMfSi@kP}IcBa+P)c@kG~9XD}DC@t&52s5k*#e6Ko3ar9fp|o;=8{AP0CD8@_ zF%W|>499U91MNpItbMY$(iXtd#wNP5CC=7yH6V$pm($E(K zwI-*U@wk= zptc0HJu8&hs;CEoVhM_Ej7j(b1jP~*yAUsh(oO+E?Fed@2kp=qI3WDMZitmb@ zI1F;)$%$vLwXcNQs6+kR6V#r~)_xo$2x?DIdp2A9XZQqy5(r9gK{Psopag;vx?npF zf}jL~5{?Une{82D)&fC^Oeiq`V=xosB$AUj*TD<-ZQ=(Ilt@sL3hvvaWDt}@P*NJW zZ<7vypd^Bl*lfw%w#mLAD4C#SHd`{cZSr&wluS_a9B|tvzXLhRro@r4p1n4Y%+d1f>#``c^2NY={9toe1ibfYsOm zf;th@X`fI!7e_^q)0v#kKGeVS5DvzIpw0w!o`M^A3W7Ql)cLhg(ky5Lg3<^|D7bJj4^BbZw3>kki#cP}kO2f^{IMD?wei z3Z+{ClmbEgD6Gq9T0pu}8ccS99cq`{rIsB^5f!meQBioMQ3RxnxIcSuFMn$uoLmc54Cj&v>j}v^O2DPYz5xtSfGCpEC zpYkXWd@~iPNlONLF^mz6;w`pu0O!1UL@VDs9tgf=+uy2&gWht`TXjibG4HXI5BWO~ zd^<=oQt%vI7|0-oFq}>7W-q^UFc5sFC>5zh6{-`S>B~YK^p1nx`G7k-3k2VF(7O(L z*NEQjM1LIgu7lnkijUTJe`6O8d)Hy_>e#!dIL#TZbAy}Q<|*FoUpWOwhs=L;Mi0+p zE~D+i==>C*AU>x@m!|?MAFcAy+CSR7M>nJ~?P$+ybfg~&9X){8d7F1I;?Z`=_Z0<4 zPhl$4&3|;n!vb_+v@VP`%h8r+^yhrZTK>mRtY;@ukCuA$9!?ZsAgB6XD1QPW65=qRD1!i#)afz(k_M}5H>q>hq0Y8^X}I!fv& zbB;QW)KOCVZl_?>HKdM`I?9}*9wT*>)Y0Y~ogS&9rH(e{=zItrEp#~AoTJMkb+pvc z<{VuYsiUQiHs|P9kvdxHXmgJ4gVfPd`%bE0^qWW>Ep@axM^8rTXsM&kIeI=)M@t=T z%+a49bhK~64Mv-D^p8j#Ep@axM{h^!XsM&kIr+vka~>NW6XI>UZfr)^%!FwQwE{OlneV}&SUBz?HKd*Jzv2w&1gYShBA!djAR@! z{P)*RAb~j~BHF_Mf;Lt3O6 zn}HX|gEV7bq!gtogYk{6MQwx`YkXr{VRU0H>e!BSrYo;Ah{5JRR=TlKL^B5Id}CU0 z>ulbhM7~j~n?BW>dbmO|xjqdbhC`L7II7T&Y9OIdUWgIt^>Ac71tPK0|4c{Tj zI7!Bt)3{$St8v>o#9@vg-MEWf;xbqHFUhm~#=^$jN9q`hQ#1g({9qZY^CU&!jz5M>)OHOl^KaudiD;d7V(?D>%G~+Xn ziL7Kty75IQ%1cN$zA{y)ige?f(3DpYZhR*?(*@zi4`MLkA-+iGn+yGLZ^k0s_!-P3 z9_hwQGyXl^=L1&q6%vkL#RfJa;rJ~`H+~<#bAU4lIsP1ij=#+vA;-IuIu#0h_n-oaY8Tp(2r5P!JE8o{u3s8 zu(cB=6URJ~ScudUB%UDggzu1Uf@PhsjUD`kc}_Ua2~KjFYy8D^Zt|EXJPiaV21t*w zPRwZj6El&I7s*dS%2JN#c$2qyn|BdzVk`nqoXUKJ zoEVPyBI(3WSiz@!&X25NEkCgxi6`!47srr#qSO;laTTd2NPZb~gVd9xp46V+NIgmFN&PX?Nm5Ue zdeS>gLfT2wm`MV&d5`y5$};nx^p%IN`G#-V$R;+km3{oqehzY$bDZY_ceu;n+z$jN zCnGs2NJUn%ksVieauJI162-&5RHh2osn@+&mQ-JRSS+dR1&gBZ*Zh7rve z#uCE}B%U14ES4bkWT_{Az)FOkEcE2?Dql7r^<=3hZ(%P|PnLS}0nQ-xWT_|r$!(;b zEcN7jfnaP1sbi&%O-UA{j+Ht#2ZfP3R_fSdR6^)jp~JD&JTyk?SgB*1(GjU*rH<{& z>qs3db?i{0kUCcC*l|op>R72`%{g{4QpZXiYtFG>B6Y0PvBn&`9-(75nt!Z0$L>Mu zSgB*pIra~vj+HvroMUexb*$9B{W%yHMCv%HNseM@xJC#a*Tnqe%sH+DQpZUhXU=g0kvdN5ICG90jnuwnIv8ipanq1GPU<*wj$4G( zaZ<;bbKDn59Vd01ImfL->Nu(6j5%&MLdX60yHK1t$DKx6-(ww&yTlc)@iY*el9qI& zCnI^tOFr^bnlhB7JhiDqUFy@CHoQtZdeNIc=0ByMhf%!2o4n0LCNY^f=8?#JB7DpW zK1aeSU$cr0Y(&B-TiA<{PWhb!oI%Jb=MZ$tZS$XErc>^6FA$s>!c3Lc+~iKo6wJ9;7U)IRiM6ar6u)BLAOJar-xPo0d! zQ|BS^REeibJoRIi^9i4_njiR)wQS>8wzHEX9OW1%xQvlby~SnGdf~{r*%i_X%bKC$Gc1>mN=#&TzFcN zhX@P#1aqDCDP}wEN7k^GpV-b0B%HR3V;o1qX{V5Gnx&m4;j|n4iZ}ARB zI$i4NQcs_PkxrM|H{J)QFW|pF|My+@!Rb;@|BN4ydb-rp|HrRLJAF6%*v~=Ea*p#{ z;0|~BoBM&_jAW!FHB!%zc!tC?T-zA}&k%Tqz%%~)`~Mjd&yaXV1tgv!@eGM)G(h4R z63=K$dnBG=o-+nAn4t`33}YFGP%~x{PXe=fkM~*1GQQ$#^PlmJhi}=)CN{H`ef-XT zB%W~=iDyVWL*f~Cka&i~GbElV@yz6;AQf4$#51##lOhzOBtp+DM+Nhr*~o)*Gut8I z%ntNL%9;IzBrP`SH(4ruTD)~p&89- zNw|wIUFk*-hG3@g!x({?#+zw;4C67=c!}d@F$Xh^mpWeR_zyADc&X#1j{goLjh8xJ z>iC~A()j%d9e>FDUgQ+FLD>D!Fz1gR5h&=jc?q)uo-XM|1=+K*-k2mMTjV1m>MQYQ?@CMQUpAa#QAB-rExsS~75 zFy{oDoFH|A)CuOC@D)-gNS$EL2^*0*LFxo^PS}Uk2~sB*bHZ7KP7pfb!hZ+cLFxpl z6U=#5GNhg*^(=Fql@+OHNj=M)XB9!(S>`*d4CSam9qLk#2DIT-+R~oh^r0{Pd4uqq zzP!adOky&zOd*l^B(Z?ye8LJo;|G3Z4gcd;wzGrZILa}ObCN4u#{Wd5`M_3)Ud zf#B@4q$52U$wOZ9k)P5?Ji9FAsg2aLrJh}%)<`{D>e=n+h19d9p52d8NIhHV*>B@V z^#o^2JzMJ8am+*N*;3Dr@G(-)mU{N5tVY_|Ke2&LY~c{+IM1J4bX;qccq@sr2qvfL=h@bk(a4V0~+%RQu|3K!9;-*1x^$=oG5Xk#EB9oN}MQh z;u}buC~>01iIb2xQR2k8BqFgNkrGV&2#Nhyra2PNH|P1S>4wzvrJmo5VT_b={+qnRXrh^dEuKG(87yESi&(;E*zEaVu#*2_ zv*)j41HZA0-R$Kgr#Q_Su5*K%gm3%ulxKlpQh*Q{G2^66WFbH1oMg^Pg(;6YCz*3n zCF;|NrbwOC5{Z)}PLeoD;v|WahGEP}BN#=D`6rog(qyDgk~m4?B#DzGPLeoD;v|Xv z;Fw^N#C~{8FiGMhiIXHw+JMAjz}DlIAXIS4YA#kcC_a;I?|8+4B&O%#)$pEnP4P} zI0TMNHGe;G#{aJxsUuQH7V{}mN2HE?$y%h2NbQHu1S30IG8!sWrg`^^kgj)CID{iK`c@)ka~eJFGxbz@B;H)u#{zd#5b(sTfS#ATiD7k?B@Un zIm~(fUFk*- zhA@<2j9@I|h+#bOBruCPypO;OKj6c#FJB||La7&i$0np+DD}dh`5kE&9^p7AIn6c9 zd7(KkyvbwCdEwJQa8ZEtnDe5HWFjBNyeK~fDNER#7nP?Xbus5f=DesOuVT)N%z2T` zUepJ3US!UTZ1$ozd50*ZUKGQ4Bwi%(qFK!0eI#D=0Uz=;0xw!+{);4DvL882>5OU!t&887Ze4~AgIi-$3Sv6%7V7{(Ki#EWMk@#6Q9c<~2Fy!dM@ z^y1a5;eV`SN7xs4d9lQccO&s)i5H*bG!ieCc=2^^@)(I1KMe$zNW3IH5--V!#7pua z@e+xbNW7#hM!cjvM!cjhM!cjxM!Y2btG?XeHh=#(E_w1Y!8Gr`5eUBjHlu%Bog#VJ zU|1`b`WRa}7;RWO0(WnzySLQ6Tk75|y&4F9&;bkbL4O9~*MD$~i(KZvUn9w~CbY+| zU)BllU-l!meVH9twjCY$uoSLh_`@0=YN7lOKf?BWsGlExkH7GvEQtJ($R8EPo&IP( zdjHYKe8Oje;Ky3@u@-&o*M98Req4lj7O{l)S%wxbPeVE~Sd!)b!sSvgA4?45nS>53 zzr`JV@+|)+5d5Si7U+|1^q@Bzkm!@$>}7u-xWWdmXoPO8uz)LCpx70wa4S|=mKB!e z)7+NiQ;9w;PD#ozod^qAjLJVtiB5c$jU41=BD0voJd&_PpQk1*>3N>f#4?3x%;ZmQ z6Ta)qJst#tUo@u^5`H1!7d`nOzp?`sEQ)L{DPHyjS;SV0Uy6B zOJLqB%i?f9Y%J)9jRpO%v0!-R2Z7*M0aW`{Mlz9=k&Hu!zM8;fRQ%P`K+rcP2fq%H zg6<4vD8m`WLC#`?Uthq2e$$Gs`1Rii_DvstG5>GO>l=T;H@5y88@;L)&1iwYXw|Do zwMr*et;4)mZ4Ly#)#h)rlN00kHZM`cF%@I{HXb|l?Gv5_g5L#6jsm_@@OQ(d`%cpD z-sCu!xWYAV1cKi;QNn&3~=-u5E{uYc1c}_3UI9Qm*|y5d2?BEZP66 zBjx|w(6seUR)1uC(C<@ohKea4r8;5wsQXLRdwn*X}o9<*hhd99Ov zo$Xv_JJ-D*2(Gte>n-Ja>DOn$U$kEG^>axi3BP{*{XlSowNzP4~QPQR=CbOx%hu*x- z5QZ}fHEtS14Ax>Js9U^k}K9e5L4v(-Uc6}Q!dw|<49wys9ww*HSD?BW<_IL}3{pov@WV(Yd(K`nZt#BEDiheg@89UHuDH;4HH zC2o^@n=Rg^%rIxf7mKWoHZm?^NH;IA*Z`p?3&89H--IHoKjSzGK%=ECbb!eru-)SZ(C99Z|AwlO&*5>!ChXs zO9ytnNMT+=(p_aK&&zo6F5TbNlvinoQSa){>lpPeW8P)VyQJUcO6-z)*F1#VWdgg5 zc9+ra`W``dDQZ`wP3Gj!hfjDq%^A+|CzrU&b(FeG^j&|W*SoV&fewsj3EH(=tUW1^ zVoyDq(ULZ_#op}chqmk)jOE;;>hKs{?zlaf`c1y7ymv zVDAgy#rram6~BA#Gp~IHzt4>Jy-XDhexJeblYUM58A+kBT>^qGdgHS2csCn0)FHKUVBKR z4>e&3)A7l6NLh#WvLEFgI)Ynt=n8*vgWK4r@F8^``X>-PoSf9C^KeEi)ZrZDCJ)un z@WX=`&L|XjSYe09qH%{U(BWCkWhtMapu=DCHF|h>E$gr`hd28T^RT8JalnyUG^QQ8 zal}DKbl|`5-#FrhN9OY(R`ST_tmIq1M@No$@exx#BJq*`-f0j=Zbg2{uE{p+XnqP( zn4%P?6y|ZX9JcOgC8|&t+j~?wN1I~lj&`9tmgQ(44DF~gj~d$1w{eO5@WkLzJvbW6 zR4mfbcuhX~jfdSF=PZA633VPdiKBmG2uC0BI1oIRg3J`580D#rLXNe^)*N&AF+Dmq zlu;PQv3D_$V-7#&@ME^+*n5GJ!DAnI_=r#Wg0E22F;h6U3q>7M)UivEOgS|yYan<$ z2f26w4Lq)a#|xo($JKPaB&8`!RcfHdOHP;$IbZo zL^SUB6b(CWz{h{(AXfeOY1HT^Lk5pu!wMhQxZ{6wpJ##KiFCY(DW6c>2}PY~PAe2} z!r>?SqH!mNFbvo1#2ZXtGI7iclntKHxf6?dAB{V)9L+nSuoE`z#5NRmLSZM)MtbM+ zcMT^q1cE0sk%esN;K|(R-bvj%sj!nKdh#W#-^r#7XD-e<>8z8>aNfx;S*{aD{rD*GI=7kgf#CU6=+$|d&W}Wd^S1f?YS!=*>#<1Z_o64~4`Q3oA2)>aCq4YZ zS#EF_cl`WA)c86@5hq{FDzvlrhmbUFGzo33sPQ? z^1>+$=7Nr0xX2a$;%4M}-sEY*7p>$)D|s;^naF~nT{N_ddB{frG~l9bx>%py#A4en zT84`TekmI-qwY(laY-XCsr!<;FHOLbUDAw8nsG@pE-hiHCR|edC0)7n4T`^HbeD|o z(k8aDjqO|r1TVYdmmPFDH+jjAqAqLRi#~S@`-;I_ImQVL=ZfK6F`O%gbLBGExXw);@GnmTK|difcr`UjyQ;LSIe7sk zUe$-I26VL?m8eP$>e7J5G_~?q)p&I(^D%*|w&&`HEaywU=37?tBb(UG85DW-FCGSh z*V18QuGx@l1t^RmTq{Lc%2N^Ba;*vacCDk|?yhz7&=ZrmHV}=wHWZD!HV$iYO;Ohr zb!~ZMWqwWD@9_{CcTMB2Y23BboJCF7EXFmByLJ`z{iR8N6{7`S``297a1zbDZh5X( zL36Ha&UMYXt~u9zj$H4860e)>^z z>UGn+ev7-@$5d}<+6`Cb##_jJL+%^z@d2Ol1KxLImwz<4A@vQ3Z%BOOPp)81Zuro; z;l($e1%fw2n8?knR^w)UIwI-K?vdgJHQ3pH>TU4mEau>v-b`X4 zOL(7Ue1t{0xdo-%N`ax@YL5QgvQ)Re=K#vO^^nIX?{<(hWFb4b$b%BYx7B#N5Z3p$ z8gEk4myWbymJXtzMG7k zyghPJfFEbf}c-R`LCZXYb%-2qHOzPsDF90>lMjtX=@?SCh-ivO{m zO>E&e4swL!oI;7=zt#A+1^W9UcX@y-@%Pg}@Sb|_r6e_JDTadYbwY9XdeIk+yElYk zj39;yBru-^EXLN{Gnjk&c<&3o@?n4P8xOYmz60);q7wCa4F}!tjXQha3-3=Rj(K?L zeJ{Q5rT0I=i|>2!eFM6`79F~Og43Ku$omFy|0=HE{af6Pv@GIxyniwTg8yW~D*ls= z9ONc1`6);dtoA>}X-t2nA;>>_xfcjNu)+_jVuBCeVjSZ!wFj|i$b$%rc#jYGNE03? z{(<5jDE`5ZtYafa_uyA{vKt3IxDyCI%t#&zQ4~cz)Vzn*`e9|NVZ9&LW*`f(5)Zdx zQy-q^A~$(FG!T5`g^%2eM=w$sNgtJNgC2)Yn~K9K~MDXNeT4tiT*vQLUn5Te0id2PaN=M4V&46 zHGSfsCpz%tQ6Tu#3!gquW(?-3mp=8d50**62lY{S;6Nh?%5^|a2VVB%;C?jaf7?K ze$O88C=d#yCLP%VRYHLr9tu;OQk3OoDpQRbG@~QK8O2+X_lhTfDg24Y&-j9se9gD4 zW(_~Fo=t4wXU+yf!2pg7HfAuh@oR&B@H7w#<)u0e@M}V^(46*krx$$#p>Sw`hd~Ts z7$X?V1ST_unapA?iLBxv3JG203V(5%zq!wYKqy%%(x5-d@=*XqCwqx9ln;bM$zJwQ z1&vD92L~jRKG_C-<1`LRc9nkuq2ykeJUzMbqU0qgO*tx}>B+q-xzx#}PA+xwRuH$0`r*90v3_!Ghco{(iD66J@R`=cghG;21!N=QXzOs!Baj@X0oBPDb<;>AVnyK zhNRSplr^bCeHzh}X0)U=>Q334eyBO6hNLw8ly6vzlmeuT@I9N^jv7;{G3DGt@Kh#|$|O>CqAT_>l?J97&M4jrR1Bq>;31A_ z%w!I_mnw+`tUz(8cCd@Rks_rv?y<*bfzWe7H19dhd+s^Xk^#+oE(@CXoaR0EGHQA5 zT^8c5Jts?Q4NqMZjZ1B?sfVDx)apz9CS!?ZDlUc(Xce8nnq!1tah3_yhs7cQIWbd!4%VIWEzc3(~&M{VwzrofuS_2 zO{1bT4oG7zsHWQh`bmE!Id?J`^x=;B614y@#E&PJQ)9ppU=?-xegGpx# z(%lM#(%Yi+DKVh*hLXN46{r&^8%p2MLlc_O8qG`J9t}+2pEpocdPSv=k31=>Y5((R zHh%JZD7^-z---sNx5()&a{4{|&H>bu-VD>9Mf1{MgBmlWCOa>X zj{+2>IHf3~X&Ka*VK`&Zv<$IK#nNQZv<#Y-VF8PIk5BlHZ74E>MrHVeJKPI|GKMg@ zj3$>cJ(3@~)Ub?)F~E!lnDL)L=y}a}zB=6)k1Wsowa=?NQ&wzICP!!L zj9-(fCrZdPm^XQwcQq|jlm`vVG!DbeG>f^+XAv5e=>tB*WHX(^q%ygAnG}-g5zhjl z%$k+iv@)kelQQR|2&R*{EET9kP3lmeMu9q^%(g7^NF0#a%4C*2^D+LyL7A;YmK1nl z7UR#N>sh=gOI2z_R#osvpB6JqM}*BHY!+d&2%DuZ{fTBFKX4SaXEn^MUYFJDvbuU% z%b{smy*6tNYGa;R%`j^h1~QZpyn*Ir_1>)d);Md}!$fBBB|ov9-R#2zvpP8IaZd3E zH~2RY%Jw{2$R3$i(Jit++3ZiYrZl4kt!c|^$eB$&+2qXD19fFH)NI30Tei_mU@}uM z(`@l5E!!O4=OZjkw$J#AReX>BgtMtN+xxnIjvO&KKgVXy2SPbh zQJPm7iH7Ag%bcrOgPG=B&o=h5pFIaRjSj37PO`<9qG(} z@94oWqM5~9=10y}vF2Mm{=%>9K*RFw;dk7ae1|Zse8)M-!$9c8oYcf?U!2IN?BZG= zls^qh%U_O)R7Q#U>(Pvsw80?qcd+L9J9+4WI`do8{KFZ=TSPGyQ_62j`K@RE%_uOx z!tyIDzb(pdi}D*n{>%JV#0_o*LIrZsoT0ppVhhY?0n7L*&^J`T3k&>=#05;Dz%e8( zaF+92#ET33!=pf`U@Fp)5yL5HI0YS4Fb~$TU}>sj5CsjQ;A@fo)im1K1!I}QG-i;% z9Bf`ebrn=s!9^@#IT~B=3oJ^(AJ~X;3tE_hJ~s*)nE%m5sNimn^9Se9i-K3Vjy@E; zqtS)bTc`qcXh;)Gq>wrbsk6{)SeiluFo;6$;A$6gwF^xmj(BFH;6h0#xR6E``U-^= z(#JxIDWsS}4lm^JLQevr!l6LPP~j9Fo+CZalZ9*)raX;#g%*)j)iuoU3mbmn7}QW$ z0}E?l;i*h#CbQ7M!s;okX@!^aAs@36O)I>Xb!cAUEvT`uSrNO2F9sYP8HpvEFCX+t|Y(23p*X9AOn$0Um=qR2O_$KgeOVLQ9o%YF`Wo{QY& z0sjVG3Ke-82o+6+#ue4LqG_;cMT=5{+SH3Ytf5)XE;@=gd7F2M#sG_IUQx{}s(D3i zU(soNf?A3S`%+rUVgX*#^p_0vr5{n(OQ$%4`d+%g4gTQ~PXeK00hAanrp97wET+a{ zImp9{6yzmJP#OmnYrzP{qG81}teC=znP{;DOthGZ7W$9I z(1#NGP+}I!EfHZ6@39ow!zKRk@E5kF#C;y}I1nnS#*!&XO*%4Q7$pl+1;v(Zfc}*1 zi~f|fF(n;dattv{B95s{XCX`YjIUT_GfRH&VJ$zg0SzpvVI@y;o4@%dvZJ)2Sb|dJ(8N-eP**8+m8wZ?n$Qf5E7h7#xLu|Cp>d@KGmH_8#R8R@jK-DHxKbKd zO5;i;vP!c`9rSRPi(EnDN~y7w=9SXCQV#;5(y2&;W|huI0Tf>PCCX5qmr-o#YM69s z#g*^jLS^!xhBEmn zND+#md1W-OjOLZ8fO^W*qArG7rV*{^NEf=(3xh7B#xj~$<}ISoxH4fhu8hW&G3YXy z7cTR)hg}@w4Chf}88w!1psd5ozRnOv z@+MZktgS2S@UjjsJCFJPZoTY>9#-%a za%w480u3uy76UA&VdW}Q6`ytGG^5-CytdrwK&ZSd&iMWR4 zKjBl9SpG}QHeCK259`^&FKlNw`#8WM9t1)avSF$fic<>3RWQv8)o=wW_#CNFpN0&> zov*M7xhu$B!5}L9#l1kNVhHc6m^08dR8i`R5?7SCVmT^dT`Rg<6}`A(bF4?ju9!+i zYf(|kil$Q0K^0BL|M4eO(S53zz(+`0@w3R;`Wo!)ik70{2~Oc7sNy*;a0zu)R98ha zsdy_8dO1Dvz1#+`eL0DBSgMzw1wxe!tWpi+tfb+Un$d#S=s|D#F_6I;TxqC>;i$9H zI3^OyRN|S9Evhu1Z#jelD=DthRjzY~d;Eh9uKXNn$;pcpL?0^`!(b{`pb}O6ePU&e ztgLC39pL{o6so+DUFbw*2UWI_l})&c7gou@3*@H|MJY*X%HhRT>d^}CtYQ#V21WWe z)M$;WvXI5twkk{ckmao4Grr_2zF{pITSYllHnS58S4I9RM=-Q1hE~PUs^~+No9IIo zeW>z?CxKAa03l=#SFP`%H67?oH+rJRs)I0ysv{UhG*eiFVyjx1s^77V9UR8tRb8*D zXSs+?sj6314W#O0o&`eH(&1yIT2`Nz)pB^qgB7V(01d3BVb$u;o{n^htZbxN&aO6{ znIvEds#$_+npn-%uBNVP>Z-PsWqi&`G_KmW{KU_wx0)MUP4lYlVh`%BW?QSBN8Q!b zT2J#-y!h+OMSDn}Bh+9_Y z9ptOC7O$=IED)+|j&)z*bu3F=O|NTv>wd#`tY!nhqQtttu^VNE>zZ8M)12obSGa)* z)%~0Mfl$3HRHgw6tf#)D=qD7s+?8{1H^4Her^u?@3Rh{{w$ zF%1>d(BTao-q0pAeA~YoXy`6CG>wMiiDfDp*U&^7F5xpapm7ah8ou>fLaHM)#uHG1lg#TD0BQH@{1QZ-gUV~01c zgGM!O!Yj1E7B}urFNX1kKY(cbwujMZT;nhkG0Dc(xA6zKPmR~`Q)EsH4RdzmYh33h z8rWC^8~=mmHGa&qK&VL&o7W^AwyjBK)Y&92MX_y7N}_p9%3<@GXke3iG)B!$)ZFA% z+S8HF8rEd2hlPB?N;Iy?cl?0HHPN^xTiM2Te&;j_ZKBX7PXeK)X;4Vh{FKJwO|5;? zs??$`n%A@;CfC&DnwnPA*EOu^5D&w76U}R?c}=64MiPo@s;H*lMNYN!FP|=W)U>8o z`HLHZi? zv$-thJ(g-%vt=G^ShMAP%a7QyX6xC)FKARVo7U`7AkkD;_M z6#w74P>bu>wKuWwVGR3q%t+I?XBx+RO<#FdNGOF z%x3}WY^`~%KVms6(6rW?)_NPe*@wbgA4b8g72Ntf7cuSDw|N=}waG+Q@=}6Y)a4cb zIaZtI9(1lvcNEg5FPhdy)7lKfINQW90|m8FP@7L8nc8Zev)eek%~3S1jjd~A>)L2q z8%=AYX>G3Z7d{4FO^fqiZN*3|@vHyiG*1Gdwi?#98ue*JQ<~9^ZuF!N{ZV4Lts2|D z%UHrpWD3)XXBOYG7sa*JoVJ?N)+F01s_k9w^N`1AR=bzzOf-`)ly)+=`-HDqhxfJH zALtosCv7`x+fLGUSGkFN15Rcg*}v~GN#j^CRVpYLz?gk1DHk<3hbc34hrm`X&pY}3s$m@ zjqJkqbU4g0PI8`$T;VTlOvfDlP@|&*Iu7Pd#xoBGbzF+X9lfyQ7EGn%L5?70$3HlW z0d@4^j_z%z5C+jn(oT6J>0i@eJ@3?#*1SqPI$#Mpb)yHp>5I+lG@Mbq#XE$ViVM;y zfjQXhPD<=#V4d`!ll+~&ViiBI7QN`SL4!M~vD4E)sI%+Q*=I;+pCO%d@&YE%xd_E5 zNogw6fDS0Kb00=9kyva@XWP!ELS0=ntjjy8YY#bOG_O}g&~=Ui3xtdJRXdy`mY1&FeLZSf-%^ z{Tsc$-+NfgPi#N~`)XL=*Y6Qe0-^o} z+&>vMufL}Cw`u*$P#(qgS5*HlbVmXG9p2yc`rEet(Trm}Hm|?K`#Ze9Rqy|ae=hE? zbN%gl|5a#Q{~u9Qf7{mo5Q^%rsQ$Mi<$GvY-au$TehQ*_1B#+~14^N$0h%{J^9E?% z0L>fF9zz@u!D|Pc3WNsAGEmb8Hl{!78aM;Z8K}O2^Dxwb%W)e9e$Gn1*06!<9QYlE zI`9|NIq*03aRAL6c$9wwq1SU#1jW5xmI^5Bb<=#^m3X~AjnKf?NAe+C*~Kyb;wBFQ zp+PCg>2HHeT%lt$V?6{(6z4620}4{Ajly3>dLyv{I8XOQU(dW%WSW;v1$`XaKb zrzWd^&gPsOLgM(=9;B;g|{=vD)%Zn7C5Xv5`4}+CG zxCV7;fPoBds>y?gc$mt3Ok(g-G-t3n2Y-o44E~nYSeU_vF?cs;IFH5+ei#T1QOJ;t zK&?a zLp5%wkA$I`7arQz!+7FJ#JUev<4`pY{g@S4@zO+AvpbSV^i=i#oLM8^y3!J+!AI-RMDY6gKP)!YFK*!iFu1wCtm4zk1xs zF7}{-!`z@@nm0^c!;W(b4IHL{!+a79yUs1{@NXbAT)o59J3I~Pd7jLuf4G4USMzW+ z4>$1P<$0MZRMWKK9Xv!4#|#pfLlO&Fj0O(>5=|Svj;(BC2MQmq;Nc1$ehkwdews`C z9SDt3*a(IBA4P{o(HjU#6>4_9{N$E@IcenR0R6+ZH3eq#^6a}doN zX~HA_LSds6HYzPSDM=Y>P*=l7>D;KsG^ZtPXiIN~qo`4e8Z|9)p`V8R;BhT#7`2{_ zY(euzY2GM99A${3)H6y0M`_w9LmZ`^QD@P#QJOaDJ`d5nQEGf6NCvWy9gTZKLLddi#frVRRk5S4y`bO4k1Kor{*YA*3YF6J_> zU>x@&RH9IcS@F>ml-1_49T^grVt9!w>7@@tOdLd>Lg{ z$QV&xyjZfV$v1H?52Co_N02c&fe0xSQbHYmmL&_GEO_!l zme9#ER-n9O6Ha~s!IA|_{)XR!P|EK7lRX*f2Y!k@PdOUxrT77natfz&CYNy|6Pd(g z@uwbA+6@9-WU@?YdkQDBN~ zOZg42X^Pi0br?Ic3%e;TRnF9F8IR&p?`HxJqqx*Wl1U?-d~9246N^w*>KZoj3Ie2l z%IAE=cl^N5{2GMP9G>RzH07losI;_^E)M5N6qh!dlR1@(xDKJxgi3ojzRx73)w!Hb z1IkNNUfMhsVEfZrS%S?=Q(l_aHBH8}0fu;%S9ycCco&g~fJc z7ry|+c6YHS%8My)+eb)aHZ7*GZGXNU8^aCpt&b_o*|BVL$)}JK%CUVhxngp~YKfz` z*nAe!Oc%Y#8?H|En1|Bo;n7pyge1Y<^ck;j4$GcEi_WyAqmvAL= zX3Lqaz-(_s_I*4~gjCYWqJUz`5I9@lY?ICwFuMoAvY+B*KIL;1mi;?hm6a0&p`7i} zUd}G;hB4+GgHSm_B1~GETqY!y=N>N;n&C8J|rvclRW7~2% zSxPr6kuyhOInVL}2A<$^qp;kE zcm&1eK8fOT(@|Wmz_|kFnsly!xr^yw6$;CJlXp;9?l*j=tlS@5{EGH+xAJ!o%KHZ+ z5Gqfoyffn!la+P9%L&MkH;G4if&>(pr@*`@3e1xyuLz~(RZvAeb5UB}B3e;io{V|Q z%d>5HHZAXIp5a9dIqx;zP*&cLK`4JeMsX}Ba1y6;7U!V6{Hqzm?ItU~Vl6N6Dh|*8#LupLd!GL_-?A-?9}@Y$@kbCU*ahJVgenlK;MDkg zPb%#mmwr4H$WWlX0_7DLV1WS^$Wo9iNEBCiG{+%h;VGPstt-5YE4hU`5xh|F!Ur+w!Y4?;qzjc- zm`w$O6$)0km>!i}>Y+d0`L8xdL!#Rp$IUzneQEB5{%20GS z_aaA;97W12GQ=WzisUI$V37ig46!IiF8Rn+RL68?GMjlc(Zvd6E$U;C_1L*=1L9HHKHFz%m7v-Hh_e#$$+OcVqj? z46)1*%WPbkoMln+D5MzWl~quM%w=@1=gz{J6hAMW8@0_Bnb6lQ>3>6o0F$%0uV8u0z;RbF*o(fx6 zp|pzod636RL1`74WTU(a87qpIhGADIuEMY@6j!0RidK{tt9a4H*ZdZQDg(mESSe%W zKT%qx(kc&N1fw_wWmPJxQn*T?DkqbO0F@4}G`UKXtDHg&wZvIS3oBUd<6f1Ts~ltl z|KVAl=Uu*F3yP~W)yi!l|K#r=RAs_d|3Jtp8(3untF%#-jwY&Nm{ygot@;cPS@kXd z;}?GCPwz_AUqNWf-l%(ucV>!rX3E{%#{*12y;IaVMV(VT#grNGk5c`g94vF`L8kQ3 zM?XWX$Ma12FbGWzF%naq>ULA*o2s0tRaB#xsrAfe9t&v3gr>e6gsOK!%xd3X?Ukyw z5!FhszQy#a<*k;t+61btXSL?5-L!fxO&E2x8&xl%6M?G*u6~NAd4}h(1=TO{DzEb< zAMpvwt2T&g#Z~`-zG{?TGtBgAc64D?YIbFRMsNs+F^Z$Oig5^8b1!zcMyWNAF&S%9 zlT0c(R5BM2P$PIvFVC}y_xLZL^0^vnzQI^)q^q$a)9mlG9T>yy+{3?lkV!m-L#HK> z&s63xpM~+ks89c`cd49d8?lYkUclMYUg0&~z+|U=%w|5vhEB6l(`@H71=T93R+QT9 z*%RAYtDjo^)as}9P%gnE)ZU7ywfd>mPpo#L3t4Mrt(CP_*4lKeO>F~>EMhr=)|z6i zP_@md zO9QG)Lyo#EvdN=>BBoG79rYMsT`P-er;BBDvx=wq4+^eRaGmX|Q*hnSK`5@cxUBKr z`KLnSd$|xcE^J)b_z@h*iJZ%Ij75v_iA2eylq#yJWd<|xuEhn6cOq2WyBqIk2-A;0 z&n5(lzh=$j@4NVjPcZVhjf)#~y)oAhV@GyFyY<5nxc*p-v)&ZzCzFU4>$O;~#d^W& zg{f~rOZ6QrMML!(s$Y$U>Yt7IqM~|<>Lsdwoi}+KD?fc-PGdZ&xbbv1p6&^!H?ar@ zOn1O^2TZp%{wBuIj8UAznViFUT*ReZ!ByNr>`7nDd`1ORsG*MOIB14CXUwCEWvsvx z%n8^YK)(T4<*W)z8w-EOVXp8lUhPU-Av#@dH2e zTM%m8iCxUUad-A$PlRdQj{_KiIX0@Z@i+u)^rVfRu+bAXUd&|(*LW4<7|)&D!@qfe zBBr5@M&TRnPGb{o_*=I_jnX$t-za@!KhN<2|K%%w4MMYbyXk`hVEJcXfgREmi5Sp_W4xM96a}H+|Ml|PN z{2$twa|xGoJvZXeIk#~KcQJ`ad7LNtB?!&k_RHMd9bA~jTu(T6Dk_>ghj}arLi0S~ zJUcMYVe`C3^De+b&3l>Gc$0T{pARwL`C;65{?6=%@y;L4k(`Qq%)gk+V!m9-HC)G7 z?&dxoU;+;#`FzRePo|W`Y~lQ^BT@Z<|T`2qnK2(>_c3$n?hFg`lhFC$GZ7t)Mr zE-=jn9W2FaEwEY(UP0>%?e;=bTPVlEbm~#`Lf^Zvjdr?N#_N2{mwdx_{J`&Q4MK|o zlpR~Ny^9mMmmHL|D2_0TgjpoOqIJ08qPN)0S8U;Xe&$#Hz>S+tt=ZI???>s)5mFJj z*|Rt2QAjZ}f;~db_PE(0%{}zR$L1;JZI|z2Gnzl-V?O0`JW%u3{Kj8FsAW5bVPjgH z-Qw();g~_oSPZP?K_21}9!I7YnObCO$wID{66$DCO3Pvw-3(z>Ti!%~7KgWJvqhUN zKVd2@8g2PA2(|9YKN!KG9Klf>&uC7@rnTCz*6X;5ag0aE*88v|trNUktTFdr+^DrxH~Rv@`uJd4)aG}Cq>H}WJ?Fx@uAw<*4@kG1%|woTrx zHapkm$=f`6n{l)mN89^+#TJad?MF1wW)zDL;#fT8;?p>jbGVR8xSXrFh9o?|Vuvl> z#Cv>){amccB@gfHiyJN($qAf-c`iAN^Ee+hE^*@}o@mJ}wBTu$Xk&>dSu)55Hu4-V z@FB*xWZNNs2cdSqsVT5-rqzBpqd1CVF|~GuwR@g+Q)|BfKX}?N<4Qb7`xqvZNj^n* zecCH9xORhUH@J3#YoARs-Ta4Vd6kc>Tl_W1Wkqc#F-LM90tkhO#=8)fr-Y z_GBOS=RgidgPj`e)L^GTo%f=ZPOI8Eg!^{7Z|7Ioo=)4->HeMW-}z_Em%oBg*D>6I zr|EhO)pjM~UFb?Di)=heS2L>YT8}mAdWPrO#4EgxL%TlbOTNazT@LQ@a9s}X`jy{< z(9&)Hc4O(e+(iy^=;vL&z&9`bmjCe+Mzze7FFP7LvdnreJDs!e^vkT~vP-#wt4N>; z4KI6vcMxWoFv|ppE#EN+EqBA^s$6cy%TMA|&fs5|^YRODy#i5vC_b86$SWdtbcAw1uaWUS2?kgFC zFx|p*--0o9*D{Y57Sq8}gzOfwTgYzv&@E`Uadp4VH!)wn!!oRpY{dZ_ga9iXzCyh# z)VtywG_gX(D=y@E?&LlmU?Pw5I8TyDE@f0Ql^O(Ev6{6E60`j)Ht`A;XT@8*i~3gl z7=%`C$1rwe7j|b4jzBXjHM3Hnm6vciS8*)@t`ugu5`wN2bY-!RVXka- z@igBAp;f!##;e?T)oGlGCtu~sS9$VP4-p|sCfO9=MyovOstRV)jjC6@#H+j!A1w8= z?kAV_chztF!CyhBCm@WsyT>#3?2UJ#=RgkTP!7lBdv0Vro~y@m^?0tHhw(f;Pmn+o zqV<$Ai#gbco)vbzXQPYfP;!rgdp_b5zQC^bY{544{2hdPZ^A*n4(fGKuL65bvNz5Q z+`QMb_AaIalkN4~y{qWOP{I$*W3S0BJZjEsL@=8vz=cIk(~ zYF)3s5JO#UsH?5~>Z_4qbs_WdEUQ1qt@_-mZ#c^9Q(m82_KieweWSPobLy)`{yzEp z?jI@ii4z+yf7o)WEe|$5?FAz&Je5!0k+A5|1$%&oL089@7}GQ3Ez=z(x(& zsDVEE8R99P#&!+7hix7Bfj@)L;LavEc#sRDA3OmMKX^K4;n@bQ)!;=~tHB#l?x1o9 zl{=Z6FsItT3!U4d4H z#&81~8FKKDgNICENFzfU8Pdp*7kOwlraH7W2(259_SPj(#1v`}be+c5wV<(e8e6y2 z)2@?reTZGzA8o9+kLyip{n4Dr$(+WSoV~K5YS@S)W7h}aCvWCf#&ZXgDWIAfrcuXy z+Ua6BE9qqo|KV9);3Zz=4c-dE2|IH(6R2e^Z}LqLjwSr=;t&4huOOVbBfGFWdvGX6 za1_UIJg0LJmva@@GM1Ye$8Fq$1AW9WoLEc=rIa(BIrvLL!ifuAw9v^iR?x$0JXPW| zJjaVcIO%Xs;uKEfOlIQylQfZ}37`85C#_=x8}YqK+rIZ@K4vpt@HOAYeEA9G>FP6o;lbG-WNX zq1Dt7VTPfN)V*-;)B`xk{8LA|I1Ejsp2r1T%w=4`IPT{Ggh-u85`v^!tW<5J3Y02P zsz9l6W-yC(x>(Li>`1B|Nwp)X>(~(U#mrJwkt%K4KQW`UQ@Dh&+|C``#RLRMdzcbD zeVPN)>Tzh=B3jW%nsKBVM_M;evx%2@6+4&qcMy)o0$;X6MNx-G)e}`uR6Wsy5GHy8 zXK+3jAy`y9QDceT!mS7y6)<`a5Ag_(VP~QlY?^7qTrm|6MlI|&a^cvyXwAQV5BqIFICc$V5Zs@_L7twbauP ooD|NVf^dO@3Oqrcj`3Kif+C9J&#xXfa=X(12gAn%EOfv1{zT z#KhiX@4fe4-rsVJ_ult?KVMwt?9MYY|JlQypm+D1B9Gn_S+%XABQs!pz=VK_0h0nI z2TTc=8hASJY~Y2!%YoMdZwB58ydU@|@M++Sz}JEA0zU?RF{;K~#yrOS#zICnV^L!X zV`*bqqo=Wwv8u6#v6iup(Z|@p*vQz_*uv;<3^baJHe;|c%ouKrFh&`pjj_fq#zbRx zV~Vktv5&Ezae#4H{){SD&tz? zdgCVJR^txiZsR`VU&e#RBgW&#Q^tRc=Z%+)SB*D}w~hCV4~m^ztaO!1}!Q#Vr&Q>v-ADc#iHlwry=IZWT1MwmvM#+fFXrkJLiq-l<6u4#d3 zv1y6vSJN`nO4AzCAEu3_EvD_JU8cRJ{iXw^!=_`VlcqDKbEb=?E2itFTc*3F2d2lS zXQr2?HxAQ#(I%sb6{%zv8yHXkw{HJ>n_HlHUcOHoS+OKD44i>IZMrK+Wd zrIw|R#mCaX(#X=((!%0z3AC6kHcPN2%o1*iutZs+EwPp^mPAW;ONynJrH`eb!!p1! z$TGx|Wf^7}X&GY~Z<%D7YMEh~W%SkZ!I4zpRJ13#hTli&sxyxYIV03 zx0bScSj$^0TB}&ATWeZtTkBaJzSf4;Cf4TGR@T;5lhtYsvW8mQTH9MYSUXu`tntl^EP>nEGA zX*Po`udRTsu&s!#n60F(jIErlf~~Txn$64RZL4dmZ}YP?wl%Z0v<29VHjB+}3$eAa zwX;RqI@&th;%r@QNw#ELPg|O;ukAbAK-*y3P+PWbxNVeetZjmAvTd4erfs(EN85bc zBHPclUu?hIR@fY?ZR>0sY@2P{Y&&gxY=7GRwjHt^wVkk?ww<+IuwAxYv)#1avE8>l zvOTrEu)VguvwgIEv8(o6_B{6d_Cj_ydr^A{due-FyQjU9y{f&2y_UU>-N)X*-pJn6 z-ooy0544-@HhZu=%pPu!ut(XW?XmVQ_C$Mkdy2i6kG+q*pM8LRkbQ_f%RbCL(muvM z-ag4b)jq>M%l@N%f&Dl8@AhT(<@VL~_4W<+jrN^uld3fo;iBxLFJT)+UQRC?qVr zU1U_J&av?cN!^oEQhWDF|88K$;31Bz?}v{ZJ#NC}snci3*+0&mzi{!-OMm@+`O0ZF z|A1V5qdk^XoHoV3Le5dmX&d~@JIcD%_uoHSf2ID3JbBAkSnTOppo4#U*Ti1^dSr2cj-aN{S!IWx8%PgYR{>D4e6nPtERu;#`jH% z@qN?w?fWJ7)Ki*&>$fv6wsd3otvdSm?rrVW@9Q`M{vG~~)tnJXvN0yKcVe&BJ(E+r zL^|U8b?r_+=5PJ<;w-CeQT{TnE;;Rd8_X<>1^6!X+gbf-Zqsm4{K=BOrpIT1L(@xiF#s>Vyr3O^!#?|ELwZfn%2?OH$F9SKw94veaU+2XZPh& zJ*eNevwxwrzh1`y(Rv~LyY@@&pWH7a=Nw)dWaA}=z6hD$F3}fss9v9@In8{RU6KNL z@!hbmCzacy?~f5~3-!s4`nPfQB^t++)R$;%L||gS_~f3vJpS9{cJuN?eJqnAg3}Tb zb1FTBiyh9LdVrUw>H9z*&J=w*GuWAgrD&_v3Py25~>MOYHTPF``U7-)_ zhn(hx`tqz{=8M*CSoFvGO5*GPRodz4JX*AI)3j(~1;=9Xi&!o;h%I8DI4BN_6XKe< zFCL0#;*}y4RmrWmDn*nMN@=B*Qcr23v{YIvK}v`cu0$*GN>`=3(nsm93{plYW0gtD zG-Z~uP+6kcHazVMO+*Ix>Pn5UHCsk1ksBUU$wY*wUt>;kv)Rt>%(nn?@Q+G!oMM6HLGrlo5GwQOyqHddRYP1j~= zbG0Sf@0w$|wnp2c?bP;ZhqU9`dF_gJL%Xj%(cWsGTof0BOMaI^F77TKE)`s=xYTy3 z@6yPnnTx-R%_Y<&+@*s{yi1Zx50^BTfi6Q_vRp>EOmvy%B3*uTndh?Djr+_jqCdR zd$%?Q2KxK9_HXFtw>CCtTi#r}O*e3xRw1H9P@OO|hBSl_fBiCz0e$c=q9 z&tZuXqY@*O4T%wIe2Owq?%$;@a_OJ#K43%YZgrVbPAjFA4(OfrDeH5#o7ZBoKOjAz zUsm&Mx0=~*wX%F=tG%k(mZ5xH7%(W?%{%M2lcJy~Bnk%%2^cPFEeRMJ;0VYH$PV~E zU|6$Q`HEV)DRA)mw3qz5n63;3>|^EqzZBmrur> zQmZQk<(N~dpW-Tao>ILG?s`CF+2^cULgwabrMN76TCJm$lEJ6dMg|XOe$LB_POBbD zc|9XQsVLLV(0z$BOsa}(a)$9&*P9wDHJyX_az@Q7_nl#~we|MmGyhR5DD`B;f7D>b zS07tbrJ-E*kJ>jhbiSuebrJHoQz%@N&lM8A!B~|~Uw9LGq7F2r6 zu@_j&bh-Y5TEWm?@2!+mm?H~bW{`8G z|7A+FK=!#ze~aa{cWO0x;4%xc#Ccri3dg_7tM_T|c}1aMJmhp(uu zl{M1ss%laGkgaa{kJnQ~0;CqOx=cimDs zBir0i!^bLbzr|*IEU(^Dqm*aPLB`)!Jq<7Qfi;uIZ>v@0vfE7djWhh! zZNlHn1$VjDa)+6Ha)!^iL%VPu-`0;cy)(07aQ5lA%f$2QmxU+=o%E;OD$Hr(xzJ3fWkn zZd;|9jC-Zm>M>okbRL(#spgiMkJYkDfU{wj{-V)2&w@|LwKxaW{t1J!>m7&a>sVPX zc)~UfajqlBZnEH0wWOhqenx=vjL@gt1MQrblaos>ddi-NbS`VdXKcKePuW==ofB*P zjNs1B`d)m-o5@N-go>Zy%ny%*Hi^#y&V zImgxh1-0ud9WSWRch208G#Li!=iB7*mz*E=l6Qf@&M_=~$ry&pLoZdMlI?72%4SxE z>uY0IM(KA@HF@$C(-yqjQ-qy=LOq-!P^5&a-o-ROKx{Nfzl-swNM;RrASV zZz;yl`We3ZQdiVlOB;UC8*I)1*LP}d<#+u~s3w!%QK=QqlJ0y*NmuJj5vHtjPABR; z)7juWo~s{k)@RP`{hkSIbMA^dA6SK*vfT%Mj_i>`KCrBR%BA{uf6HSZxG@jO5>kW0C4Ie%;(|6MI3rqe{27gh# za+|*BuUB=xKJ|-QT2b}c)>d-Ki(k~{hCI&xo++BgjHX%vo&q?%dd}%H;x!MY(2Sv4 zh~manni`7guhnvP6|HFQ?jioc#_+ErGne?^S3Q-|`fM8+%KoQEd6XE%)4ANaRIQky zlKx^PXP3UJRzj)jT%tr(D{rXrUsujkxw4krqG~M+b@X;0xl`4u$jX{lO!1LTG_ARz zf!^*VTWVTaIY)1ABv)x#Q>CeVsA-K2E%cY`%V(NKeO>6^UvH?XHx!p!T(o?0yo=_p z1j+?2S~JBgFS%$wicPxa()^TQY0kxWVRBF|It-WVb7|F-2zfb|)@prHfo*&}u7*^0eOAeP(X0u970_<<_bhdg-yXWn3O&`{mX=4Sn>6O3sE= zxmlHd-_G!q4{~e84FkTNQA&E|(TXU8Wc@shV2JFLhv+OhCXZHM878;o(R`JW@_8Pu zj$w>`O*I*mOY@ZGyjo#pyo}7t945)Jd9_-~RJkFqRz;a1FXh$hDYInZeDv^xY@Lr8 z&664V81+K=Q$G6pNuJBcbxUP|{8}C5H`y#db6GA^^V7>JIVZnnRo2Rz`XB3M?E+e3 zWs~e)fHm7H=NHgwC_Cie1?XkB{9J(R_Q^U0S(m?LOhJMU%IO8U;D|g@kR?AZ^B2;* z4X5;hdq~$pTG8UxKx0TdYrCj_H`zb>YqfG-lbMAyPo7cd7SgKnjJmfF>v2^+EW{XZ zNRPsFblbTC&k9lSl){wuo^%u@{GnV?n6W>R7YZ|m=hDqp>%lW>rmGgtGb-O%WJx!z zqTz#neSpk%)oRKVH#W>?y&+icDysR&qeV1VdBTle71^?gW|rPXG&i1LIqqP{t)FQ5 zc49dmV|NwP8tNChNk4arQBXgz?o2nWh#c*%73axzzPr{_ahC;(Y6(2g_9?2_c%I!_ zRO{q?o(*vJ7FtXz$@451HcT&fg>YAu_T(poAeIjEEts95EWQWQQ&)+^0jFu9bL zSC%Zz%tG~shK8KyVQ)FPj8;;PEUlF_wAXXKHteE-C*v!nxu^ULWS6fYiV4E7H(2mSkAYf05# ztCsVcO!Lr68G8TcWW6DeT*eney53sBdD<}#Hbj4U(}Q~|Lw+jDyM3LqS`kC0eohrx zyPQ^9#+T(Bhy1RrR+Go;g=M*cN9cc)mygS8g_P0yWi@%Su2GI*PL$ccn!8LXrx|&? zUR{p6Y`Xr3m#k8r9VzvGePq0+R!OFo=lyezY*|UGEVDc{7rDH=R*~oKW979Vp1a#r zrk}c=biMf7IaPW1=H=@0s3#lZSH0t}*LqaYD)0o}qJrjYSgD89b%y*3L*9_95A{T9xEh=rzqPp-^6 z^BH-lGVdhkWcg}Z1?g3VD=x}bRoGHj^t6WZcxB!UW>?WF@o2uS3S01&a}wpNb32!= zs#WKyd~#K;yrkJ#!y&4Bk0?8eug4?L!@-8NnL%sn0hh2!R%}pQj_cE zduio)tY=zfq^B2eK@atYGO|ETtu@c~VKp_Qp@M#qNgl7IHIgT5ve1=fo95K-N-gfm zVzqdC%z4UhuAfm$CfDNj@X{}7>bzuUEiFHf`fRA2OZ*IV^%on;IB)LMM&4}9`m&R^ z)=BY`8@yT8#`2xF)|IFH&b75}Jm9Zt$(?egHa{@}oZBJPhkDRZRJN$YdzI1I^}nr! z<-j`pB(>-vA=2MR^OYy+FvgsRev6#qr`3}+>vF?{=w}4UXN@&KIkK)+PHCgxVBXTT zAuE2ZE)N;)M|dXOz>ow-0x2oV@D8 zYW4HsKI|&D`~Q2D@r<%riedf1Sbp25hu!8P|;Ix%m-0TzdOy zF+BYj`=F6bYpA&!CjZxvwlt*n)0~&*X~Y`O z)L-o29B+Ih-n?eZoyLF1wZD<(W%yA)Bh-0@S7Yv=`OXz2tfU;#m^*!uKEkhSe4;UT z&(HE`Y{W<8`0p90vojpY4iycg_b*~9n{Kua(m0&?nltQmFvr#WxZe>Bp|JbxZc(S^bQb-_CHAQ`le11wG7Wxct8_=91-F@epTkM!q8f8=~q<=*+)+0Xt=_Nl%j!VNheFf6h{Ywj3J z0B66}cNsq+joi441GuN&>8Uwq_(`QT`|#s`oe|kutB~88^PwZ>l&Uh=$Pf1wH1o-W z4cyj{OTXUVnGh97a-Of(2l9N7UmgpjXod7WT~4+#@^jS9IerFEM=7fBwJwI7&l+v@ zE-K3)ljdhAt@ru0VHpidS!bVh1DIn0GtZx%(%;Mvmr8QHncJ|cylB?qlp3?ptdeE4QnUer^+IYo?XEt%00uWs5eF7pgPOnSujtfQF3cAPiN6m z3E@p6Rv%MmKFVwl;jKAQZ)`4OL#bqUnH{Rd^MU3@sMb;GC0m5?n40r3rkSkUM&ofb zjE$1>F{ZKJP+hvU;VvH_YqsG|9i;!^D>t;^y={oR(1sg7OIB#hEjdi4wPh2Glq=iv zCOJmBg!99Eyz~#}C;TLt70yG)RJkjhhoBiUe>*MOFiW37bLrYnYhLK`lXM6F?)V(bk}LWZK{?&)ttDlrI5x1091_Q# z&MmLSvF-B7I`Q1X1?Av)-XdJ}#^Ul~JdaN9`qjB^E+)F_9@Xk(r4+rZ(3H zSX^F7|}6 zHa~ZV*73c%CidL0H%nWpI(`u=`5pOgu~+;l{t^eoA#qY%6?eo_@kz;}6jn+qWt6f? zImJ_{s5DgqlxQVG8O*OUzgNa66ZpOO6n--@`rL(dBN|(-zy)L&uT6; zkD8C)i5KR#;_hlOwZ0mxhN^8;es9Y!!Uw2>)M4e+(W+E`P#3FzsGIri_c8T`dRKj- zK3Csp1^DccXE>X(@`mced0NZW_m5X)2FZvKTKU{QqM>IU;lCksgm&EE$SZ`oIQYu| z<|?R$8mI|x)Il?}Kq~|w5GGh)Ln0iQjdj?A8@Pq%_=GP)SolS^MMEy+MiC(#mZBUm z082@fh6l>Q4>6dEwK#|CxPyCmfJb;Ogw+KF!S&V(U=r54sE-C{h%j_TUyQ|EY!VJ( z-NL~(?7%KCdh0&C6T+qZ|iV+=qBzQ;l=0sYxd;1tf_ zEUw`(%Vv9u=XfcET>(pEXNl}Ak-aJ?ft}9nl)-L92netb#jjWc25$cggxDW~!P`Fz z5yaml2x1aJ^c6&@gP2SZlL=ylg6J>E!IA{^1RVy^VNezrdC(|~!8lC723){R+`=6& z@StaSfsaB26A)Yuo?vx?D}xCHSBDpBp*AQ@un)}CKiJBF9l;1i8-ybQQHVw?;xQbn z!7>J45h8?D3MmUp5W@K(J-|dlMq&<@VFY^R|)Y-o>2 zM4=Ptv>lzcW0SS(iC&=Nc75?324E@92+`gJ)VO^#`h&ZuJ$s@(tJ|K9+5SiTj9(ml z`3)>@`)$~dzi|*OS^Hx`M3ezDk6`0O_@NP+pe2}n1hbD|_7TBo2L=|wU?N!lhVAn)a&`3IpWcebQZDcB#X=FN>O(cVk{2n8~Qbsbn z$O)JU_DJL=>;Z$1Jco<80>UHp;Kz81=hQ#)B?qtZR)`M8!LoIT0K2n8CJ5_5SO>y7 z5Y}NVm{kXsx&y1$VI|gJ9d_X_uzDSi;5g3W0xsby7(@rcJA4u%%9Z*@mExcx>c9_; z(F`qN0=G+)9U%xqM|4GRWP?o=H3|$YidBf3jA@vO*`T~p3^axGzM<(lDbx7J?ac(%=II)TtHx(Hab**e|8Z9KwLJjW}%5h6M-il8VefXy0R z4PIbxMb}0U7-Td9jE+YFl7%Q9ox%YdESh>pvy9QdV!gaPU90A}%i#k&7NT=5afpv1AU=mKUG%jjbJAsfTN!o`lo1WX14jAeka zi?IaEI+jYut^}2iU5ouV0aiKowGeR(EUqHzzz>Zbd})T3Fu@826Bmq7uuO3*ZQK}4 z1^X**7Up0Fc4Hq1iaUVAIEIrrgL7aT#j%azxclQi;FAzkK*Tecc!w`vnuG9o!sFRP z@dU@WMLTe2JcEd5HR6*o6j@*u;#q}wRw152#s7f0pj7d!Li`F)uJ~98DR)V1fmcP zc634tdZ7;(R01oMFboVVVKinyf1P94ep^s-POY;ElTQ0pm_&+=+}ku_-!W0LVNV{#tEg`y>KqL4g1o5EG z-RZb{KV;y0Ou{1k3>LgQ{dC`p<6x5AnPhh+*_}ytXOi7-;3;=!cV^iA6_{oB_xOk} zLL@6Fjt1anNv8bC<3Z=ibe>G-$$#Szj)IV6LXrtdz6b)6pW-!Gy&ieMiuEW2Hxz|~ zfF6}Vje9gfO9a3O8@OA0grW^1k%SaX1vT%n78}9M(1Wt|(1R#b3WHB^0i{ZzR4D|f z5SUUHo**-&D)mpP!9h*1#3@bD0<91XZibX_v_}`P>?sT&h1#aDu~G(r@6)Krxg+DU<;- z=~*7l(Gf#16N|9~zhW6yg5aLy^gM!-ID>Pz;NZ($Ji&9k0{fun7a>wru!O1HPN@tc zwFn4IW!X||pcd-D4~@a!1A|Gk%i?pjhK^|*(skNwP=Pcm zkhT+h@F)Jl0UX9roWNtpzZeRNE+Yqd8U#8WUDfOL#d0<)k zvMhbsWqn@@kna{U43^Rm*jK4brcq&e-W^6 z`Vg}(EZ4nFBDx({EfB|H%ZW;a1AB1MG))~KI z8CGBw*h3lYoeXN0aR^7jL^9Yf8JD@UGp=!P6L)YAukjY|@kxk5F-Sow2p!ZHbTsH3 z=w#3(Toqz)D+D7HZO{%Be(*}H$0o3O2JZj^8q9zOA7Y~oW<-OJN#>7Wc{6{)QZVbxHTVM?u^H^5%)KBi^CYNxCWFmnu$eb-AC8B7 zc>+EBr4U0Hzz_y7Bnn-?at~p-hcNpgX&8vX7>X=V*CDGx_>gsAGD9wcfFZZRiVh)T z$P2v2JAA}vA%>Qx{zIwt(5~nS0*3YlGabqdhq7ctcVj;e;4landKN7G(92*pLkStm zY=#mvlmHf0I5ZfL7p^D*Cg&)L(rAikPIYj$gL~aE4ihmMvoQw@!oeUMETMyN$1$*! zjx%5>9fUiWse_s7pI{uX@CN!Im}M5_${L0d7=^JyWV^r(?kHA(FN|n7gBU(u&Re8a z(({II#SWYTH|+3pV8X-698TtNGKb&79We3XOnmrbAx7kf7r1do^u~0s6-Ka7BR&Z+ z(gTgbqK`Df0vo~+jTpu~GL8dEKQaMHNXI}>_K`!ughr0U7_ev~*#aXE;w%{8NCr5P z0ghxrMn1qJJi%unSOYPtAc}&wn^9C^R9RF;HF$|wG0K~RI_LmaY}D^qhh1PrMzJEJ z2pL7kqv>$81{QL3DU?G6(BbGBsEOLBipq%A8Z9v!KVU8vU@?Bi zQv8NxSOMlb`o0ij3W09Nw8J3G!!}&RJ0ZrZ$b~$}2MRux0*qydj1nPhM34APh>+(tc7}Da1$Ba#6~cK1)9i! zCNhwTU6F)j^ai05`y&H`@e|JAjS!Or3c?eWK>w2(^S&~P4kv{o8u4Iele(cNsOF?T zpyNs3gI*>vt4T933lwA$+i()wZqhQWz-oLFVlt~SnZU`ea7S^JL~S$$yJxaL`+9O9 znAv2u&}5c)at{mudv)>1Xcc!nP{?n<~ z^!3h#R<#dw7T!c#U`Xh|faIs0IRNP_h}UX!{${Xh zGg!45H*p8|ssD^8U@nR^uquUGBXns!3La3NoFp`Dy#*Q znYjss&fI|`IEItpU18=~oW~_N7|qOUV7X^L!c#oQ2O%W;K^l+;`A`6bPzKyalKRQ2 zs0m6T>!CivkPbGkWIsvDD#u|mrh&UhO0YT(`6~yEShDPLC-&e^90sG7CvgUhS~5`i zScq8)7~CvwuUVzxjk;)#02pCGFhbE5?1Ncd!D`N8HD~ojKhado8o~i1oiz-McGg%- zz)Z{qyMGp&W!4I?S7xmROFQfD4Ud1*UgyrYXtxlH_TjG$kJo5#ayxbjv1~8)<8Ltf zWsH6qYrgD~5XA zT9pnaxatH5T1C*Rb3&}9?yDPtoYmy4Ziy+}Qmf~2K+tM}R{xAQLad=)YY19H(3?(0V4c zJ~z1U*C&CT_2jH~r1E744uPQc1o2lZ#D*&H0YMuG+Rzx>78_=PpbZ3Vn2%@pEW}2F zHWIWk7b1}Wf;JMgF$LRj0OV{WXX9}pHdUhjo9c2v&?bU5HN+Uq1VNh!+B6qW@DT)U zCTO#U_UHnFHWRct8C&od2--~0=A%MvsesxbXA3!78ekNrVzOVq1CC0zumd+EyPUFck!CV?x_z;{o1+oNeT6`{EE{ zdmF@npzQ>0Ps9f71wq>h+I~=o9cAGKf_4zJqaKD~G6>p1&<=@vcnyMf5VYfy5IaK= z4RUspv$HGyfMYjb2--=|&cB7&RR+~T&@O^@)j>8Uf}mXl?V5obc#7wEi8n&*wju%@ z&=H-n5?iqyJF!QIJ#HumPwKy?A_r`?J?Y58_ZSXpwC5ad<1X%l676k?AcPk`ag7y)#j~e~i7$#U?LonuJ8CGBw z)(Wvd7mA=LilY=#Fc5=~2}->GI49x1t1V+%*GP@g5R(lUxfI( z01Cks?np#m^h1ASfIaf}S)9j3ToK{`d*nb1v;sK?j9`x(_zC13;O`)b1HXe!axfnV zI!Mq#YIKlIa6EFuqVjdRag%F1YRJb5F z^*nk?9q9zS{ls(-bb_E0ydj->203TQIm2H2$Bquv z|DTQ=L?affu^l_H8~cPfTLP6)71dD_85oJt7>fzGfV;SlhoD5~{1JjMv;`$Pw-oEJ z9vi9uIcjviAWFjnl?$`E$4pg3c3ko)TSXi69VkfuIYN=)%uf1A;CP zbb%6G%#V^F=OQ^5DbYp!zX@L)3W6>Ybdefe{0BEd&_#kSJ`mzkbJ#%8C4w$dqDzah z3Itsu=n^HmoEODG&}D)yQ=-dh$OJi;$+=94E}zDAI0(8-&}C|Lr70{R=n6qsLa`7l zK+qL}uKXdy)!Zlwg02#DwG7zpSNmfi24M);?N=}2GOpr=5ZBo3*8&g-6ZOAl<$z6c z?I$e3FZdnrgt%@%9^?Zhy3QWC-V?ph8qh8*ixpO&1|<5_FTGo7CuLXY>F;Hwn7g2YYY~1l=U)CMCL61AZXr7D2Zt(XC1N z5d_^L=oTfq^-74_D#*D_&h5PDgzo4;{cjU=n;PBTg(D#7HbJ+k(Vc4W1wnTRx}9fS`K> z-D`|-m<59F5p-`pp5e3Lf5-_0-Oq(cB!Hm%1l>=;HXHy!_X)axT!;siP#5GpAm>3t zjG_JyW^zE#1A-pR#S?r4K@SOfsG&W&fS`v2JxsX9PX_B*gPjM1!E`1U>JHKd>9*JSXS*-$J}714nhf5cGne z7j=-0i6H0&K`&!33?fiwb%)QUJ~?jzYwoVp(@CEMb0a4 zFzHw0L5|}UL9eFa7M_EkR|LI!C&X(zI)I?p1ig;MYHSBVuL*j+Plz`qP#FZhA?Qs_ z48d3s^oF1}Q*Z-MLCza;-st!LTdNRnBS6qwg5Gw^p2o+lW+}>LC`yb-n|myy&3I5&U3Gqiv|reOgH z%0p0|rFbWlyao`Im!P}_5sRK6Coeg9)3FaHgyP6cP+o%ao)b#GnrH-q@)4A;C8l5= z2+BuLzMt_%DEVDLP=12)=SOGs073Z)%HIb&a0o|m43wxq75Kmxe$>A}V-Ch)7Utkb z%*Qi)7D_<@6}b?J1SFw5Qm_es;x8NkB`Q=NwNM*%L5T{Dz*J1fOw7gu2VdUeJwD=# zPztl#3&$W1U62TNd*Qtxs4zi=4+_PV-R|lIf?Nr5tp|3y>tqn*N|37ryWRCQ$Z;jd z^^;KC*z9i6a1i81kXu*$f!!d;jUczbg;Jyps)L{+1Qn@+Y)k|}MF=XwW-D?BFF{Zd zf{L)&+=I~(1i6#r&R%n0i=80Hog9Zdo2_UmR0Tmr2`b8FE6U0g9S?$v5>#{=Zs9oy zDoRk%cS0#CZtj9)d5lYE|C=CykLj|NG3*TcnM&UHB<0i8#c^lNIR8v@B zLlCG@sfAd9RagT`R5~|`qBu&T3{o)&nHY*}oWxZSRQe|F2&GJ8m_SY$a>_V@`7$5N zKu{Ti%B&TN2fN*)2ng~Z$b%Aju-iQbf*=oqJQ%#kaa;yL9t3$%qO$eT3IPa&8MCnj zzu-44#~12fu7FU=6@n|=!EP_t7yZy58Q70gID@mGMCHBF1kKO_l&JhPEWjfCgr#^V z6i);4ARh`M7Cq66`g``~ARYT~0t9&y6dp2&zC(g_f9tc~}5~D*O!IkSe-> zpo#=l%n#m>D)sr%_TU)EsYp)6e^|mwHQ)z=DiKtvIVRyp5LAhvN{jJID3w(Z zRGFa4dC>{oK~QCaDyLx=j)0)b1XX6URjCGFkW+=6D(tl?6Tn|cP^u7Ag`g^Iwkj`# zQdNMUssvTdjVL66psEB_O~noz0zp*?s(Ml=)vCY;1XUxbT4RjEERa);oNDv&44;Kk zog7DXf~w~NZ>`l6Ku~ppsya@6-DwLWPP#Xl*B&cQsjKXvfRFj~ZKj0DG zgP@uO)lv|SI1p5epjzFq34el|TIAF^EEMnZs73v~3Gyb$yFNx>DhTo>$a^*(;4KL9 zCdm7XP-+LGBcc(5c&x=v?8aW~7fPK{sEX?Ff;Y&lGaeK5xPhFf@!mw1hLLaA#< z2M|=3pt`YGjqM<)EQBNoJO)Aa39A1}D86R2 z13A9r_;$i_Y^MIc1o;x=yGtky+~Em=8W7Z=8Zs~v1T`S2!312vLlD$}paw65;%7u# z5adUYUle}FMr;N-emjKH&<*84PQ!}SqG1&bz;F=Mkf4U+a0&N8P(y+mJ`+l#)(8VZ zjR^gmCy%6k%jLu0*-(9auc_47Y~Heq&aK|LI~Pm zF;-y>)?tHCn&w4ultgKiMH(_eP*Z}M4#RO=##LO$Eul1Ph(PM!%*25OcFe_ZScVl? zEtKXOTu}r?Q3A>6kAWD3AvlVQAgDP(&2I>$1-EUB01(uIoEBDa+qU=xOmTb0G_0b9hwIZk$o2}IhEW%G% z0!q~CgHZhQARh{V68XndfB#J~=C9g3P%Pi3AX2CdizEZ8!je%mkU)Y?ex>3xX^JS=elrF_;N*EaX_&YnBJp z-}07&_xOk}Lb0|%4C2rQiP(U>_!EEOpipe3P!-kT1xjS2oVM|ph{>QtHg>!1IbPy5 zD3P7bZtsAOTwsqzEZFV#?bwOk*e8@Ac6(4|5EMjEP))GggT{iOAcBIXfZfi&rd5In z3L+@zjZlKw?ZFWsD7Yg!gUuei732hy6YSU{ln{1%NJS77LQqHz48~{>6hctQB(U2< z9)q9|f|ASjfeP&QlWa%=`cp#+7p*}~l6339^731hE?Wl;aHksJ^dMo<`= zE$jmB;yxbYiBQ`3BLrb+i}qNGby$y$*dmm+1yLFvD2ED2M;5-vaE!t^+{Rt%-c_v~NTG+t0@`tiUR)6-q=d6hToGM=7LWAO;~5TpMv1=W!92L5U*kqZI-W z2uc(=8%yvDe#3Hn5lRO~0lpN1E8LNYzUYVk$iP9I1wkDM>TpFUQT5OQ1Vs@PWrV~} zASjBUsNeBPC>`^GoQ~vlbVFD4204z71a%yMzi|cxbtI_cC82bxgJvM86G8l=MTLKa zsB~Hcf;th@=~sLZN^~9&6irZcVZ@^s2#O{s`aA5$DUcIQPV@!p-`QIzotuE5&IEO4 zvvr<^1t6$1L7mxbo!<#1#sGq12#P6)So8!zF$Be=V;@d{pcsN;&IyHolc&Ts0y(kd z#I^*tZR|V;UkHjNDE4P?-^RIspg4l!@`L*}t_KK;BPfo|7RPNHcMJr@5fsN}i|4kD z_X9!k1jV!2;<;_(f5beH6TcYTwp~<^(}lmytaQl>?%OWiK~NWhx};$jj)0&p1a&zr zl&+Of7e4SsLyW;p%)%VZ#S?tQXQ3nrXlRcvNI(*jsec0ZZNgtTfI~Pc6#gZcl2{uA zB@&d_0Nl5U(?L)oL5V-$5#EEKM1qnOgd+|FB@vX=4V&;M$Vnn6>9A0`l}9b=-;JPd z1a+&A5ts^sx)IcEHXh(D2qw=Y8J-Ucxss5?R36R`n%K~Q&sx*rrua#?tx7RX7i zhhdlua+0T0|73}KcnyM*2}=GXlpc0;Ku1I)7OSxxJFy#Vwv-a6jH;-Pnizty7>|jV zf*W{>=Xi-XLh0$S@+ATU@lT1Ao}IB0TR~7yf_m-|N@_7w1VQ{OAccPgq@)hUXb_Z2 zQ0gRH!hJl%V>}Z|uhs}dTeL$Ye#Ls~-)kcWo3TwOX@%i|a_~eY^ubVM;d@Y`w103D zw{aJgsCRSN5QGr4!D6h!8mz+xq43Xgls?5#5~ZnspRydJArl1kA*jzV9LHr`#dX{g z3jZEQ=^F?WETBYv=i)ak!wRex3jZEQNq0pN6vh8hbk|WHqe+T2vkcIw? zWHb?sXP^1MdlDDD>!Nor1cL9`_V;SzqW4_ndvk*Ch53Zf_?)kJ9tggloRp*{9laRF z2;O59J2}izj&mvy99fDtiLCBRP3o|SmAGi6i$;FIe}Ukrq_}95i$)pIs2&W#MWb9a z>V15)jylL8TsF#OqjYT4Wv+0IhdkmjPXocxNz8w=hmTH13bK-o?AU|RMJP%!d`yq7 zLRC~gTH&L$f3$gzZbl0_(}k{dXD|vKZM>t0GXWhRJrTR)`-&oiqvtuy$7lZNk63{& zjMjzGW;xpOjQ)W&Y+)Lqfk$Q~KV{~*( z7S;F$rQjH;$Gk>iq#h&nnBr7M>M>G}sYYX@9wW7HWD1Vygw$iC_RUPeF@unLjMQU{ zXUuq{9`ov+NyIQ0p~oyBa=9;3kCA%J$9#v>W27GQBb$+WjMQU(=KxZVk$Q|dkGX)< zW2E-YPQfwvk$Q~OW6U`s5mHA;9bwE7naP6C5yl)DQOH5+2&p5?IieC$M@SuE&Jm4} zIzs9QbB^eU)Dcogm~+HHq>hleD=h-C;JA#}t_^N;w} zLFx#pBg{Es6H-S=9bwK9e<5{*)Dh+!aUQ87q>eD>h^gY(=CVEA?1&9@`LU$C|J2`3jD0&D*@g`;251V~A!N)0s(_ zcs@kJu`Bt6pAly44tDV;`-qfotR!Raa2ILDN-{1)0;Cxy$+!$;M4E9~$WH;J87IlO z@>HNAf{d$A1B4l8eB;_-bmJ`QxbF0%xA~77?l6LPk#5{%rZ5%hd}CU0Tr7(S^9i5w z8K1L;wfx2gjBnf)4sjl%8h4S)2siE_Pk6=)^B*4w1jkD^J~w&DOMc2wmU5J*Hg%|r zbmLpohPRP!d~f>Dmv?v{qZ&U7qZ%L03}#~)$IoY>`H%mMANYx1ScfFzB^hr{E57C%^PgaD6C|Fn zo{j85>IqU$*v~1Xo*?yvbKFAe2~tn6uoGT-h}4l%M;V~9Vv9=tN;ENX{M1c|MDA$6qG zk+WEgw2{kL!N+{cT7G9cJJ`wLNMDX{l;d3CIyaFpQo_jlf#Ad>NHZ}D*~!W4NH?)0 zr6`Sb6KhbDS_n6>C2!FR;U@N=C%t%;ZsG{uG7S77>SZ6Q!B>8LRk$ zpZFOGC;rL~{y@TsyAf{UF$A1=l4}S#@rG~{C7t+`|L{&ud=UsvNPS6F>PfX}fz*?vp46J|NIS_GC-r9_gNZQzNn;(xF@agw+DUVm%TkuJ z5~(LiJW1k7>yU1eWu3H-103Wq7r4kJu5h0RJmhg8I5`oCNkXznUovB^le1#BlM7Lp zA{3(%m8rs;G@>z0Xhugm(V4DDH+c{eP9DlQ#v|b5i3m421_38W&i7>*QcnH|Nhg2H zcdX_IHnEv4Y~wE^p8Pk5IFHnmrJj75dq_Q5>dB7+!6^xmdWzIjl9CCbrwAQ6C7VM* zq@E)6l%iBb>M2rBsY*kno+9;>rgT8+DN;}A!T_Y6BK4FZ80i$Lr${|Tho{U&+9?ZI zM3@iw%>1XUa`>Dt`I)u+!f*V6IZxTe9*%LG6P)5YH~5EJ{Kqq%^D+>envztcCM`M1 z#p`%=rUY+X9zv@3nG8=Me3C8zA`tY= z!oeu1qoj^XLk^^lk~%6kB`Ar|QDw|Os=9;JQBp_MrUg<*NgdUi?noUabyRPLBXyM2 zQSUPusiUNhie@2FM@b!J&QYHrb(GXm#vHWdcoTH8)b(GXm z<{WhusiUNhGUuo#NF610lsQKyN9t&)qs=+`HKdM~I@+A0iz9Wk)X~NqT@9h5g^sQj z=}U8@j+Q#woTIxTb+pvc<{Ui?siUQiHs|O`NF6P8v^hsFKGV{jAqVC;JugyEm)JLh z2d7IsUE=8yPp^W+(Gyr6@ynYEYBfw4fz#(VFg9;2C|9dWOU^1~ZX4#F+n#xeoJ@ zZpLyxVkO^Vt}|9+wlg-dnJsMNFAgB#j6UWWyHEG}D=dDT~$kF9X3@DM>?mq@E@4EP-bUJS$S-SrX5Zc$UPoB%USltg1*nOX67) z&uWUqvm~C?i7rSy%RFZd<6Yio6jO;Jn&~VejxZna8LRl*{AYdX@H1=qh2Qvto$O){ z$2iUjB%XC0iDyYXOX6AoA@MAUXGuI;;@PQ4O6|*$qZN^@ zeQ85mdeNIc^y59ubk0adW2SS=bj~zpV5V~_bEKa06-GKo>N!%+S%;C% zk$TQ<{=`Tl=SV$A>N%$|(>YSlk$TQ;o+0%dsbd19Lh2Z)W73fesbi#$$xA7uj*;4r zVhG06MCur+W9sr2QpX4#^EN%r-_K>KJp5`5CEWq>eG?m_Lv@M(P-2jyZwx1 z{zK{*sbkD}Zc3z{EA?D+o|_Y?=Sn@-oadHA+PUUCw<49PN<$jan5J}~Bc15N00t5{ z$d@6EWgO#C)ZE$3A%=N;$TF6*f^Ybi@A#gLY+^H8+0S1b;2`Jl5k2<;m$=J4^PhX) z;b9;+F98WjL=rNRiOgiB00k*T5h@_@yh>D|0aDMCdR`OSBlSF~=XIt(QqPlm-e4ja zYyR`3o;QJ6NIg&Ld2?Bc)bpgC_Ywa?>UmPnTg?Wfowto0>|{5mxxqi&!d0&Gh{rs^Yq~Hg$w*F0vXKL0Tqxl} z2^SVbxP@;}o{9*!upaekXif{?rY-I0KwtVH;X)g`a1^5%gV%K7bfjE36G<0-Ks-yZ zunWKBE563UF8mE6UAUf&?7>JE?&a10|7W3@E;Q4HXYigbG}DDn6Nia4uQpdhVPKr{D;*_K+Z&HmKG^GWt5ZX^V2}Z_B94m3G#IX{`N*pV3 zti-Vr$BsqfSczjLj-8Ffu@c8F;X@?$BT|B~Un8-fj1r8MICeb}`w1z*SczjLjukle z6avSdG5=VJVS|IEmvV zj*~b}Vm~-07$>nG9utg{I8Nd?iQ{%4u^${0j62LxB#x6f?g|peJw)KR$2>LvaFReU zEOA)ku*Bi4NF0_pEOA)ku*6}B!xD!j4oe)CI4p5k;;_vQH^X*^JJW@(bZ0O_7|L)a zV8r2xOn!Bki^O4b_7i7<;g66yEOq!3RwH#->hK!2Aaz*k@OJ)2>af(|BV0u4u+-tJ zJV5HO)Zr(A;Nrvxy;$hQk;#3@g4BzpUi=z`k$SPzi;GhksTWJ_r`7})H%98kQZF{^ z#hs9PvDAyZF$k#_OTBm)v`Hl7L zVmEu(%Lz_$iZlGfO>Xfo&w0VifIlUL)TALT>3QA!KgjKnhkTT#3}tzPTGXZv^=L(F z+R&C>^rjE}c#roP$!Ma8W*ReyBg|srS*0Z(Nc_PUe8pO%{y^#v*0B?*Kal!^KRJ%H zADra^m$<@x%=rUz{@`&S7@r7pjyLD{WMsyegn<0X#2g2eF>$4ealI1pSS@e+xbBq13RFOhgjRAJDBqlGhR{;GhSlGOWM#DGhSlGOZw1{_b}rnBNE>Xf#!AL`2-i@AU#d05G%il#CmXGEP-o54CyXD@y<@W-?kNo;sYqD7x*(IK4leO27;d^wj`fs zaL9!Ie(Ep$RO(Non8pldqXVBl;Xi!xd={WB7U;7+^kV=!5a_eR==5hgy~+ZvYL0HK zvVg1JMzO1YWnH8%wq@1kK=AW?Nc4GG-k>52`G}Q#g37;0gHC*ri`@9@zL>>g;#tb_ zK=4aT^ksT7l9_BwB8GV^Aaao}H+jl4UIc<)1!+SMB>YOkuijxR`#FG=UmXbqzcz-i ztMDe(sf8lH{*j+pixK`WKR$l{R}SO;UnSff`9I10w6UO{HWu{L#)97@L$%*zB|AA8 zgZJ>8>C9vf3jQ_;e*L#8NKIef<$XpG!6~j|gx}u6f_~SI-t;y9?5P=WSiWDj^EZc(@|WX*;ID6B$$qVal)pCQJJz$2&1?$YzXLjq%Ze1Sop)KpoYn}A#Eay7Qxo%Y;xZaYjx0LIpU!Mbi&3eh#FX2O$&W%QfZd^H@kncPlnCz-ux-6 z_=2za22I@j1M1qWuFaabc^${lh%Gtr*eyMo&ev$>mg|Aw)+A`oR>Rzy84cQ+hoY3w ztgWRT%29zzRG|S)X+bO6(Gktu+KqA8nyoI{s<^Ety!B@kwRHm;w{aI3Z379t@k+UA076PZCAIrn5uS{&2w`CjCb(n$rUp{V^Dc z|M0*+=J6pb`4lPt_#fZ$Js$jr3IDM_ydZ7DypexA4+M7x@H*_widS%FPF^Pu`6)zE zic^X**u0%B5pL%~40)%u-l;*m@=+W0?V7+e1m2~-U2|E?3jDDp!Cg}Cl6sdScPVn0 z^t*mw6UMXa54;n*_VYJJwCgB7yLKyhcL5Z(TVcD)Ql2-dfg*O-qXEOvl-;0o+@k|~3R8m8NV=yIRj7ss@6r7|Z_$y?81VadpzV-S#s_%q&*t@XAY{;jY)ip-ux-T-qa|0 zZ(~~1mJW2nlI$If9z^ba*TE|8RpVYY?j27gvzbpU-krTmS&pXd{e>*w4fZ?p4@c zh3$P22<{7#loTjpUpg{i2>bM8pX~bM$dEA2 ztG@@`ucrM=_>Ld2!TW#a7uKZtvIEj`eJHu&r~(2;KRq!0a=$tr$C{|;DAfDJgX7yUeN zki&uC-v;t`8Cszif9u8HW0}A-pDTaII(XpUUn2G268|mn-&@#$q<{a(K2CFvTioLz zhV}Q0K=5D?!#b!V2cq9x6&p-eVy?#ttd#&{0mHyhCU479G0F0}Vd($l)m#>X14Q2S`PF)Ok27 z7V2&d0{20UF5mP>LI{aB?P4;3O z^~N1dhYdSggkqGS6lEz-MJi+Ij#j58jj+8(m2>nhEZxyw^u@9q9f+YFRpwDcJ30X` z(a~u1;Ao5{AD!=Dk&edk3x~PDb#C%6vL7{wqt64uV}@`n0f|ufvDYX`8LH3#g&gaG ztvTlMV|sM#eIhW7V-qouWAg*Wg2!Bb%(@)=j4$|_)%=L9KBlN+hH&gq_-+;r3k8D5 z-F-YMImu04@}q&rHSlwy}d<>|rk_IO{izlUC~FWo~ebJ6NkznJGt28qk<#v_wZvS*255=uS@tVl1a- z5knY7o%)$yQNSsepW4R(j&RINbLu3gF`85V@+1&EotPA)CLM}7ot4+fNpUKNmu0th zeH~5a^dKfNm1wNQ>6sYH>3J+<5sQguDK_}@QJw{YXEgjwZQfxvU!h@VuAr9#rq>Bo^*cruL>`gyxlOHJ>yf~b9nM@Q5_<*G> zXC+@4!o{y0zQvR-{)mDux!_W1DpQ{hxad+JM&N;$rZWpEFPZ!$lfPt8mkjCBmze$~ z&%Gr5rQHa5Nytl=F_=p_cIkF_Z_b3tBQHI6{*M=d;AIP_Z@vAPnnva4MK~YyV?`k>9QVm6x(UogSc?}m`lm6Npc;L0hwB~Jk(hr@uHW*X7=6Tmf zA^kO}uPq8!f8FQK7RTTD1H-;%FxSp=5yQD=IM)p4+HLM|pNAOI^&kmJLNd~$wChT{ zo`?J>@wz@-H=yg4sZK5G(#XnRZ|cy3w@~Bt`7FZ#u7A!~e1muA`dWTt1Dn{%VXmRb z>kk6K8zC}bV{R0nC?zn28|A5l_xr}1H1XTrjg}4?ccVLfcn6cXF$|5n@je=NBbtTb znB1D??i*X#&IwMVaW^#XhQ{5v!gbVi!(!afxEuFS-#?o4PZ{3EWB*ygW-bMSH#PI7 z<+)iC&AF*JH}&SG-rV##aMH~XW`n+AL{f=DJ4#Z2Zfj|HsZ0B11On;Ldg zaW_r%rfJ@M!ZTh5f_^k@@Rp|C@~Ye#kKDHw@)=+7y?->gwb8)?ZyiGFTN2-r_|{GC zVoh%O(7NTpx08|r6S!5FfO`nI=4$yHU!&sWJ3_+n_)HE@M3>QP!OhiBR61++hM9csG_6n7~~VxGU-1)%?Iuc<|jF>|zhcIL$e%dTMuP^T~5DRy2D6^68-ahUGg7-5~m2N2g z{)ha^R<^T~-5lf;XEpl%1&7P1@xB`GTcG>5c@_vhu!#@65)YD)k~E|zBV|zVgB~dE zL4O7@oc9=sEqXAGnS@z}K0f#aTl2sla1?x?kq>_4XMVvpKXk#v@>Hh@U2)OF0gS=} zAI@PeOYzW$9{SKjAAXIAKlI#(2J~kqEk`A)Pz?`$+MKuONN23T zQ=ND^94qj23}czVMCP&-;hyT$Qxkt`Kc9xz6ik@*)xUFls-&m;Fsr9#_4FE+@~L?} z{g-<@PpW6Bk>XinTGJM9*t1UbW-!_k`D}#4`&h7N zN`I#GXVaO(JQlK;B`m}6o~`FB3Ve2#2Pp2D!k!1PP|p*Rfy`K>=S5M_b8UTI4lR9N z%}37jnhtd^tmpdp+y&1!vx_6x+~+QOt^+R;;DIl)@fv3J!b4wp=nD^hVOB3Z_=Q=$ zFsm14^`cX_U|}CQGQF5Z3?F0nUVP4%*uNLw@;z%Xq8CQ=VjUaUfpT8_i9x+M$~g?| zg@L_L;tM6d(D)a3xEBb%)Q6YW>Sa<=X!Ogp4jS@O{V%gpmG<;u5JNGHmxl3j9Fa^W zifI_e%MbaEA5h#&+w<}yXZQz~zr4>Qp7A1J6eI{#3^OlimEu^*|_?3|9tQ@Gc+V*9NZ!LZKuS zqz+B-YeKDPLzh4(GSt_hKZ6*`2;O5PqlscBbC`!>Ly8S8;X{7q6bcF5<}MF-%5z=@ zLJ2e~L0U4PKM4v^loEl+P=eA96{$isYNAmI2I7JQ(kIx#L9XDU1or}=gaJG-VP^8- zISI>Afy%szru*sHp@dQ=lsci*3ERO2MB;ihqzTP=i`KNIJ?c(8fWfFa zGO>mvHvYt85g_qLtY;U0VH$~#aGY~o#B>s0NAZcRK#~xd$Vwg*m82G4fg}n@;_@UW zk;EjD^q@D^G08iDa-k%n93pUel9|k90gH%7_mV7U1>dngoVbL>xj)HG{tbkZCL$@x z(Y&OZmoyz2$%5u3&4K16)x4zDP)pK@ti-#PRF-5Ko~#ram&{<3y@&FWDKFVLqKIL> z#w9b|WMR~pOpVFZm`shyzTpRc;uoxSvdwr=les9_g+M5IVoWx3;&rkGqKlWS!1?ty-xkpF}asIxoVRuD7g!gn@)0FN&Y+#N+D4S7p2HS z5j-%3RZdZx7PO%q9qESYq%fTno}0pEq?jCzDXH1tI$GKkYuLq~?8Abj_?yEV;{>NT z%XuzhNGX();vNrp5eTKU=_w5>Wh#`IQi&-uVOS}%Q4rgmvLt0RJ7on24N0l~l-23P z1ZFXhg~YLhW!U1BCYy3KKVkqW|6n(JQFzLuC^+S5&S5YqZ9&Q>flw-2lqwAdl&V0W zR47#?hpKqDQZ=I`t!a*S{pMJc6esmnT)ry^=hJ&LJl zTIv|)V`)-rT53&8y@F5pjBi=TJ`|Z+qf%exKVAevX;NTvX-qCnW?mx~UavHHDWhp= z-f(bvnz~ryG)-xNtxMCE4s>DwqrxT2_-*nF$FFgJ8VyUc6GKb$Cu&KfmNXic<|rDL zM#Iva!2r`3VA=qhk+u##>(b7^v(oytY1N%BCzdFktJC$wuSxd~N=WyvrllL_FoB6o zM#IuYW5d!dW(mvq7>!D&;B=;(&ScZwz@*Z7^U^CMeFBoAS?NtHeHt_=eI80;IO!`< zmFm><+hqEN4ozr|ElWQJ7o@i`=_OBpo(H%ngO$jT8V}5nAMZ*A56VyrP0!GP#x%pT zGT71#-H|+l;Tb(VqxUdlWwbD(OES77qf0V2 z!bCInVi@l;nz86)#wqAx#_7yrF>Ba{cQNB(j*-ngGrBnA1uk=yM}bhLgy8|@yi;n( zWUDgSs!X;iQw!e0tC;C++S7@y2%1SfnFP($4|QcS;!Gn^Tc%0OWDfH%;Y@L;EmJ(J zu!@OmiHX4;#;h7blxfS*> zb7#6?Dw%sTmSz(z!z^Y~b#c!9H*E#&o4i2JgnH7@x3>VR~%-6WV(?BRolJJH% zG)~j9Xj+ykxI2rxv$Vm`vS?ZsP0ONbS$d#pS^8o-ve=F+({O*5UHlUWWlc*3Ix+?g z%W9ZeH?SE)&AOd^9OZ<@Wj*b14mD;~qd&$VlvRycA7jh1d0(<6ASo%Z*4bQ?tpqKx zy4f@+n`vfKST;?{HjELx#}uM5#cc6>$VYt4m)@&v|8w|`A5d*J1!Z?Zb}N-#SF$%i zqU=wHeMqqxyeTX3Q?5el%xr!@mdV-d+i`k1ECzbsD>@dVH!Cm6UB69q9Hk! zvP=_l=tz#02$175G$Y4aeq#e$+0IUOb1M+aX&O1R@;Z4@T+YIj!fNNV+Bqwsi8*_* zh@a4qoEnnz4ClF_!kosP%L8+zM(SJ==aM*AL5d-1uF`mLu3FTgC2!N7PIRXyRv}kE zMlvP5rJ^<0m|QaDa(^!M=i1AD4xnMVj&PilSb$uHmFohR0-@JK>VTg7Hiy3RB8$O1W)k?p-J_ zx59EOEVnJnZHsanLhd`fD&i4O0--#4X~X+WK(Tq21$u?@tZ?{}pYgyve^RMEtxRwyoQt4aPqn+Zvm`h-U`$S*RQP6!yMm5@AJAlZw&KTKrCV6 zS;{h$mG@&lAd=|2Z#b{uD_2k#I{Gam`|Kle#E&mpNNAvRUMveK+I{z8WI{ziEb5q0e|Lbr! z5Gr861(4V zx5yRl2SP=KEGlHtG^EFN6wMlrsFpBAWFE)-6yi-ROwn4@p&kusLNhE+(N?sfErwTA znMM21k70~qEI$5>JN(SAtY-_` z*n!3t)5v0%!e3UmvF^U zfw!*sU^K4yyNqNsQCO_vbI`cr8drR=W);`D;vb^c;ufa(b#8MPjVt~X%`2{XC4!_S z1DaK$5JgdViPBW03e`|-iQ1TS3B{Ed$Ph-MfhEGkYgUO>4qx*v8dpN&O8mk)Ht;9M z!*^?FR+2!dWO7oGmjbAvWD$x{k}_ysNzE&%c_pi&o|5%xgrSyfPCL5Oi@x;7pi8Q; zq~?_z&tx>NEdWuX|h3)KSFMn~EW1Qr4AXGL;E=;v-S<0ih zvZh(KHeP|UK1a$np&29b%9q`V+-1*jmj}EEbPAPA5eSv@z;bzzx}3!2BraE(>R8uu z-mP*TT+WA4IqOlbH>Of<5K@*im2xgBXEOd=pisG)gu`2EYp}Pw+zxi(?sAr*+(jFGFW;KC=}JEaXmI(# z4#Rkt_Zfvc%SSVd80Hhl2Uw!=%lM7cD6qWZ%HQK5|M4Oast~{iS4c-j^sho;ilL7c z%23JQKUS#fP@P(6WCcyD-~xXhQK-Tn96~25xTwPYK&YY#SMyj zO;lH=tD()sFZ-j8eJ)wLkfhi)P(kQqbGfM2g_1v1O`!QG!aZ;9v`FF zN?wgh>)6Ku&fxM&SGdk?Y)U1)s$?LQ6Oq*CW#vo`K1V9&BsT@HB9)7xft59^a>H;; zJiITzt!n`ZK6T?P=t|JZ{Dy=kCt zKHxD=1EFdOP+m3VMOIT^wM=A1jn&jxO^wxx<29@1t*BNJjjUFK+PJ7%Kc+ID#VkQl z)l9XTX;#yuYTvLLQ>}I~5UQSYW^VGaL`BZUWU~u`1Oit-hR3uqM^N zz=Ny*%6c|oJ*pqTT2w#EDbC=%s(z7sfl!S=xNAerF5y_3ax|m~&1k_}SdJR)upl** zRii82@s`zyM7|nZ@YtG3DM>4oUemJF)byIRx8^UbV*@+bj}jwm9&|X2I%}F-%`5!F zZSL|26RP=~mw`~N9MqsG3aq8TS_-VynQqwLTD=&70&CfxS~HleQkx4NzhoL#xxCE_6r!I`3$1 zoq-NR7=~Kwe8+lBtj_QJ!Jn8$oxia#bxg93A=J6gf4m5U>IPAC-4xi^x{9r<*t&|X z`#Qy`L2VRMS21-5`VR)dYjnFc6Op^^)#*CVUBSE&8v4O5UQ_P^^>5u`iiPw z8cS7Q0rg#8zabh`za_1B8(UnzuYWO7zrVvs#-efcC!ulmr!xzatZ#kme-R$hRKwg~ z-~IJZ^6KvUJmfJNSYHDh1kk(&iAYLvY+i#**tQ0*q0R;cDTQro@CKUKpfXj_zy^(J zftnkrxj{z_YtY4^J3WbFCExNB8rNVQ8_~E18rNVC`}m9F*tP}=ZJ^MGiOGOM8Wy1f zE^lb<8(RB@^=X9WHEc#_4Qpt04Na@zaNc7Si=H&olV*C-tR$tWMr{nGSwk#MvzCU? zthK}2$lq)cEBOM$Xl8qxskfQ!ZDxC$+1_Sb*oLB;9p@(h@+1&yo)|?m&w+k5cX{(N zyg_BEVuPF4qy?P=`9jTmI9QYB{TRd$hGQ_zCo?x3-_q;t{^ss)?*8Vsthu|JpX4-W zxxi(vaf4f^tNC3tt@(>Us6~(jq$Cqw`4+E{i#+5*^I8-_%`Md2;!SF3RExR}*1bhz zn$n-ye84hRV53`TUW>2!hVM{x3q`l^s<$}IF%;h73<_?c;1>UI8`Ey_G!SZ;gzV&` zAm#j@wp!MAXhbX8pm8nxqL7w@(6pAC*3ve&G|iUNhz;L;OY=568d}R8xVxphTb@JH zTH3mnwyve7wbZnhn%43j5AZSYRz}Lvjxku`x3+R65Neee4Qo}KCN$?QTGN?6yrX%o z209Eujjhz!Y9digXBP8VNF0m#jiV^8mFBe4oK_~;N>Qzz@iGu<9U>8$)w(o2nZj%g zrS;c*%i2JBxXrzfA!OQw$fiDNA{L z#{k=Si=GC1VV3T!eig=#3Vk& z;NCWttDxj4<$ynE#y!Hb+#=*6>4BQZR=rk+cu*mmZP zNy_jB6{tZ|x}nGp0~yUMVz4nCY)gla`HZjlAKzgZ9X9*dZ5^yahd8P%b8rE?bBN)pB zY-7hM%w`dq*3nzmQQaLiuH$mPW;GhuQR6zUW244()VPjn?|3c{>XZ-R!e6M?A({3 zXj08UA&Q9G^)!dtl|s4vSnTDdzba-UY9-m zh30iR!bwhJl3iXEc9)mo)9p2`cp%i({awq_fW|aM1G{Qq*VeS91M2G9mF{R@S6kQB z)^$~9*LN956w{f>95k@&eAM4{1>TabYVP_qt2M6cj}AZc3+n9pBoOMB7>(WpN42&cg^eGf>x-hyXJM*yzZLUU47mA(w{+S zUiVR`wfhvJv3cER6T>{z-`&8w8+dmE@BXExb^q4kd)A=-?&|FRC=lwAkQAgQ9T|Cz zTzFl3bc$(e*I79-GZz`t8rV|h5XaJq^64x_hd*=f|4X^D~Ff+09iR@Qjy%P_Gb)NJ$#flZht$lSOskh|>$RROY-0x+*h|BD zT?%jStXb~w?f%|bad+?1l;sU7P#I0^ZTowxtGBv(Yhv#Pw4^l}*SkGE@K*F5jK=kT zmywJn0yXzmbMLtKHXk4E)Xj~tS>$3?(^|5Vz zPKVQX)vy$SP+xcVO-DhBPz=rMTMEtVTOKv_)x5r%*H`oUYF^(i7-HX#@YueW1EGGh z^wacyEf|8j`o*F-{q&~aQacf1nsG@&^f_|6!Qr0xGE zwJ?eP_3_~T?dU*X1~P==^&b6iBT`g@!DU*{(O za+mu&;tBr+LIY$Skc8xD?to0>Lb(I-QINtEr8vqSpbrC-J)ka)G?RQ81Ne#urLD@KHxCd_y>&}7(yWfvr+(;5414@O@3f`3}m1U9;kl< zo7l>MCNZ!R-LcLCHE*Ei4fJje9L5MH5feV$UBlcz(ES59d}^Hl&(EO={DP z$;>5|F!3yBC7+;yLliwk(L;V`5BoTP!iOk$h=PZl$FzrB;a{EyLPHfcRAK%&>Cn(3 zylKmZ)^KQwH6PlBc66pIJ+OI0ZQ9Vta6~T+``YnazGnx!P{UBe8>)drHE`%*G;in$ zPIDIZ486fEY}`=I8~Q8|8kUHp*tlV-NrxJTWg;(y(70g+K1|~xhw0ofugWmp8>ZG_ z5lmw?8aHeK8aM0%ma>fh@g3{ghQfy_eAu5HDwqr%&JYnY)8U&WVfWD9B-zMVhVjphy4yx|8h#Np~0 zu7SffZMY#0SI_Y4Xxea18~!p78WBSCMyPQ_awH*5gIolhWRXHC7)u#BYt5W3LmkBo$O&BiXEYOBQ$M<;zsH_E!G|y#Z~@nFM||-EN;9;SVw9J% z2pLiuX-19|IZ~9DqP!GCOzB0Ql(iUQiaaUGOL?Bx`GAl4lrQ)%a;7LS#kQpe@tUUY z$gWCD-Oa_G?2VkMH**h)OMQeXJci;@(}|HyE~VJE)K-?EtklPOgSQbN^=rQ4M}Ff^ z{tg7wwqZA=rFnSTJ{*qX(vIT<6qjb}($3~wuH=^Z&kriiv(pMFVjc^rM|o+=OKYJG z+n=@qgG{q|Y066*LdLYGc!rmGhxhrAPq2AuvZpC8?N<~R4q(t>#l^xqyBLo0!lSv4 zf1|kYeLTP<9z}U!n-|U@pBX4EJeT<_!nDIm3@b4#aM-lNCL9(p>{SSxYWPd6eq<-5 zMRs*Df&(~|Q5?nbC@*p{3XELLweiayR+`~O3@?&R92p{Ws6&p391#UZnrK0R5qToE zFXEMr$P*c0h-Y~P8y9&C86z@AKH^h;Kyi^j*b)e)hm@ASy^CQeFI~p;tGJc%C@x)b z>55CYap_7+Pa>5tIaII+A=BIG<9S}%7U^*tY1`$Q+e9>P?CMskG?dfndfq7;wf#T*J-W${pN=;xZJM z@i3Ellr-|0g`gP?m}JHVoQ|% za{S0C$}+gDyP1GtWu+rSRwmixF^wWhC`X>G87MBx=4Hu~RgZ1UvTa#CtY$6!$eE?E zte1HW1JClNWPPZoEECVtUiP*eg~GDW<~%Oo5|o#%yzHyEoqwaS?5QN8xa=^B%g#k{ z*#c(^oNdzC0%mv8%>W9^{(z5_mHnNIpZSeH1HqhaP+pGma&}~2M#U#Ts;ui>-o!0D z!W3l4Ng#<-A}BCNfjK!SFh`!83Y3;pO$`fZMrk?ASb_3#WXw@sj%~}aX*n;jiPx2t zW6C-2@;-kBg1Ltq(C(i)$Y+RmKHg6_#S&Y{-Zz=68XC=zZ8$xk;&tS-To0OLKs*5*J zUY@M^BRQJUoW!XpF8>_HVC(X)!L;-5;ywh=7d(G5CY_&31e4BJUVb6f2$nBcekX&x zqO|e8o2?FaH;8UB0c$-x3HG?8^S};R#B++~t)huizeJD458D$Wb6if$|Cx zkf%VN0tFT*u)q)trcq2Oauv*DAq_OrLMy$jL)L-~Jk3U>6jk7NN9hJv__vUdw53yYP-q`xwPd zQ{1$#`Hmj~!NMRT;^PvPb)L%$xEQZy;qAy!Xn20HaL_Ln4i?^zAr^WA3MU~?p#lq) zRv1N|!fcdQXwwSg%*MtQ%2>FNdOBEztc86HD64S2izj#rIg5s|4+nAxhjS#Ov2jH< zu1IM`wyx+RY+KRYOhC9Ip^B7KRDl3R9$sW}MJ89&$}*N??Td!|&3Dn`E;eD)imZ9j zyL`kae9o8HwBqgK3zHP)`Nf`Jd^Vn4d_6Z}c*P1VR$%eJQC{&q7-F&QE4F>bhFENf z#Wt>3&f*+OC}%p#E3T#nnTwl|xmf1nPP!FVyxPSY-sG=9uw*AjVAD$WW`7RA)|DKJ z(n^Fbxsc1blCfNez$F5g2wWm?iNGaZkrDw*OtYj6(=0K)k}i5}SBYtrShbSpd5PC} zgSSy$$!30!f0?YTqXNOwV;RlGT#5{(S8x>yELC9XO^o9a%QpJ@TcB$fGrJ5^Uf%ZyY=chohEXXi+L2+e@EBgmZD^pt8VT?lP zvU3=VvdV-j6RIqYbOb2#@G_GtGr6)^%%P6?wEMVM*>V@_u;yioD|;HnmA%Nzyviqh z&oBIrsg?^_z8yPa!sYuSWcf%ALL23|Xrg=?rd4ih%fH1T%YWsMSRhytAcUJ$?7)Gj zyTUtDF@cFZ#3ZJm-U@YAsI$T;Di*PrCYI7p9}ZP9$OfL|88+f@6<-8`(|2a;ziFIq z#?#$=y0p_(HNA#f)HHnojkM542gWu1tw6AHcP_&BS9-xJ?MI~%RNjT~mBLpVN2Oh@ zG=@s|tZb$gW3P0dSmi1gJxE+BapiNoz$RY7PE@|hJG{pSe92~1S!pble*}Uvwnck0 zjAF*F?1sIWu@{FjilaD|(VWN)=09V+3n^zjh)tfM;u*=LVToqM$fAfi%{ajf!DkHd zDsS*9|K)4G<7dox#@2B5Wu|4GIf8NA%L6>@<})XgKr$XWGeRk|F;l?dbGxKfU<$cV0=2v`!U^6YS-{l#svTs#e|6{c(l~qYsHJp(g zgvzQ^R;9A4W4M~z_z%)oX{<_PRgWTkmGD)@>_hxj z-aw%EyL`r%Y{m}6EoIGi>_lu=Uv^_})LpYb64#u9`PP_gO&aM4TcgGrHP#4LBTS8@ ztkF_UH>=T5jfQI0qoEoN)o7?jpc;W{-s1y4!dnrWb#Nd!>paYERu*nN%Z+C_;jC7c z;Q_NeV3r5WvPgcxV{rCp&fx+s=2EU?EZ1=Z69{AGv#XiK9Oki*2J@e-&e<*W(#JZS zV73#?ew?RJ?d*+seQLcWwI^{C2~^_stQD|Uz*-H|_T&5fCc|K@25OC@_I2Jw3$yiC#-YAx~sSb;p%Q+Joj)P-toGJ znM4J1(MFx{b#|w&m6ZrzCw!gob;8$qQ|eyfbN*}obw37z^R{6x_C>0B$KXAkr?PoQ zFz*b`=3Fexq8Q-Goxrtl2 zoryfeB=cW1#l>StzDV*#Y0RLLm-sahtT&c=H?DW%`b)SRW2hgC-KaN)dSj@!ANBU5 z-hR}3Nc|j!*c=EpSh|MMoWkjx#hAdp!G;T6IC+DUH%QnZRYN#FJln_cYh2c1oDHqC z(}8g|7-vH_tFc=RcB|p-Kyb0v7hCSdrnXp)#knk?4d1(XC0+E=$9sIm5B$t;{22%? z2`OjE_AZ8@?IpuGlLsk6NlWG<%o1Ui2(aW?+;GWu^@@bjqIc$ziWyqG4I(vG0b1FYw9G}$b0^G06aCA1l9R$Gf|TF&GS!py?FTa2Q` zC|Zo7o_*3oU;If=e~9)F_r7!6`W8((|~0i@C!5 zmtO7ST5jMbqBy})4_oSCOF!i|Y;3D4Tlc^XTaV^+&cQreFXU1#M~kiQ+v-5Acd;CY zY1KxngS0-)^Srd1FR$<#mZ|mUcw3&ga<4$JZD01sxY~|mG$-O9ZKt8IHV0}mwKhMB z+OFanuE#mr#*s!orBvXBYOBWN+Dxv^}`j`b;LY;`8I?&giE-eBAR%TPxv0+y!==GU`rsl!l+g_`HGXUBP;CZ ziZNV>)330bE5>piHxQu}4X=2OkIjFDG%KW8DZt9zaKn|VTxrHD&*EIp=bxDKN(;Es zjaS~roh)MwgKR+HmD*VO0@_&l3ZJ3!PS5Dvo?-052=ni>Xq|gw(K?Uf7|gJ9G$(Ni zr{jd3XLA`>;SK1#o^c4%DNN^G7*l5*Ei9*#ZdN0t--8?M6tdGkbZ#{NPV?&gh@bf_ z5L{&!R*m2=jzEA_9`1MK23M(f)x~IHm5Ntg!L8iKLrmgPl1L#;I>pQ+&TQr&(5l#a zU!LG;Z2zh^cpHne>O($3eXITo1iN-(S9W7h_Caf1$D^4p&2$OWbv4&=12-dJmw;Ul z@GwGl3E7ok{#}xGN!m4?6}*5Zy7$J7yWP0^JTAb=yPdq-$-AeLK@Ry8Qil6_Mo*>(@1H;$_?|Y9^_Z-L}9L|v(!*SS! zo;$b)C+%_49w+U23bML3bzVJl@y&CBCpk5DJ?E$OR zzxpta;Aot3^$DEJXqfnfjc92BoB^=Er8QvU^9jPv%JRR7J~!tGeA{_!|Z|Ghj) z0?DM|9Q|Ip{skCDzxC?3Uj5dq-+J{MO8=8Q!*jfVRqOv0s~hY8GY}lujy*U6MGl;f zvk#2nLN3Kx4P1$}8n^@H4k&j(xdV^j?H-uNLK;|tp$!N!AjrS~Lp;q!1R2<5+5@k+ z_>hm0YQX*v3NWaVLFErBe^4uf z)SJ1L+qsi_Nu`w8)G~)UT3AI7eXL`U4ZOf6UgZtm<~=?LgdW?S3z^JZ9_Rf)DE8P- zflz|qagY#VTefEed$2eAax}+r0w-}Q=Wzwsa6LD18+ULQ|KS0qP)Id#YH+B8CHQ47 zp@a?>E9qh_1FYu>p2BGoUgu5T34{^_NF2ijT*M_bWeqyQR7a{8oU?936Ij0Yr*XA+rIGM^@v(vCwUJ&pR41Wr)u82oFwHW3rt{j#9!b)Rlnmal4?g%?MSK}NqsTq%gdNq zswUE;O*;rPO1psT8IMg)dyt1oM1Zsu;y8Vp2c|8?L(@9xMk8s)k!BoeL%hPfyvK)u zkPq~Q!u}a^C_J1Is3`2=VfBR76Fv&ON8TiE1~h+2~&E%l;h5Nt_b%<#f(O#nCZ1 zZPaO_P8)UF=#|{eBjiy-JL`FlPx(6#%J9GpZDow*b~KY=VKa;%BZDk*(NIPOmNz42 zdwoPM_TiIFYqT@0-;Rp#4`7Au{ZlMlEXNHqd5)_&pZvyWO}F%6@`4LD3m*%Nq9i66Xs5% z3hm_9;xM@uEVmx}mAev$^P!?p?i%_T3fvgVecZ*9IB}jZdFOFIQ%FT^d1~{?pO8=d bg!24->el~l8~ooN$L9a BackupGym { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupGym( + id: id, + name: "DELETED", + location: nil, + supportedClimbTypes: [], + difficultySystems: [], + customDifficultyGrades: [], + notes: nil, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // Platform-neutral problem representation for backup/restore @@ -131,6 +152,7 @@ struct BackupProblem: Codable { let isActive: Bool let dateSet: String? // ISO 8601 format let notes: String? + let isDeleted: Bool? let createdAt: String let updatedAt: String @@ -146,6 +168,7 @@ struct BackupProblem: Codable { self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths self.isActive = problem.isActive self.notes = problem.notes + self.isDeleted = false // Default to false until model is updated let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -167,6 +190,7 @@ struct BackupProblem: Codable { isActive: Bool, dateSet: String?, notes: String?, + isDeleted: Bool = false, createdAt: String, updatedAt: String ) { @@ -182,6 +206,7 @@ struct BackupProblem: Codable { self.isActive = isActive self.dateSet = dateSet self.notes = notes + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -232,10 +257,35 @@ struct BackupProblem: Codable { isActive: self.isActive, dateSet: self.dateSet, notes: self.notes, + isDeleted: self.isDeleted ?? false, createdAt: self.createdAt, updatedAt: self.updatedAt ) } + + static func createTombstone(id: String, gymId: String = UUID().uuidString, deletedAt: Date) -> BackupProblem { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupProblem( + id: id, + gymId: gymId, + name: "DELETED", + description: nil, + climbType: ClimbType.allCases.first!, + difficulty: DifficultyGrade(system: DifficultySystem.allCases.first!, grade: "0"), + tags: [], + location: nil, + imagePaths: nil, + isActive: false, + dateSet: nil, + notes: nil, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // Platform-neutral climb session representation for backup/restore @@ -248,6 +298,7 @@ struct BackupClimbSession: Codable { let duration: Int64? // Duration in seconds let status: SessionStatus let notes: String? + let isDeleted: Bool? let createdAt: String let updatedAt: String @@ -256,6 +307,7 @@ struct BackupClimbSession: Codable { self.gymId = session.gymId.uuidString self.status = session.status self.notes = session.notes + self.isDeleted = false // Default to false until model is updated let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -276,6 +328,7 @@ struct BackupClimbSession: Codable { duration: Int64?, status: SessionStatus, notes: String?, + isDeleted: Bool = false, createdAt: String, updatedAt: String ) { @@ -287,6 +340,7 @@ struct BackupClimbSession: Codable { self.duration = duration self.status = status self.notes = notes + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -321,6 +375,26 @@ struct BackupClimbSession: Codable { updatedAt: updatedDate ) } + + static func createTombstone(id: String, gymId: String = UUID().uuidString, deletedAt: Date) -> BackupClimbSession { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupClimbSession( + id: id, + gymId: gymId, + date: dateString, + startTime: nil, + endTime: nil, + duration: nil, + status: .finished, + notes: nil, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // Platform-neutral attempt representation for backup/restore @@ -334,6 +408,7 @@ struct BackupAttempt: Codable { let duration: Int64? // Duration in seconds let restTime: Int64? // Rest time in seconds let timestamp: String + let isDeleted: Bool? let createdAt: String let updatedAt: String? @@ -346,6 +421,7 @@ struct BackupAttempt: Codable { self.notes = attempt.notes self.duration = attempt.duration.map { Int64($0) } self.restTime = attempt.restTime.map { Int64($0) } + self.isDeleted = false // Default to false until model is updated let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -364,6 +440,7 @@ struct BackupAttempt: Codable { duration: Int64?, restTime: Int64?, timestamp: String, + isDeleted: Bool = false, createdAt: String, updatedAt: String? ) { @@ -376,6 +453,7 @@ struct BackupAttempt: Codable { self.duration = duration self.restTime = restTime self.timestamp = timestamp + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -412,6 +490,27 @@ struct BackupAttempt: Codable { updatedAt: updatedDate ) } + + static func createTombstone(id: String, sessionId: String = UUID().uuidString, problemId: String = UUID().uuidString, deletedAt: Date) -> BackupAttempt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupAttempt( + id: id, + sessionId: sessionId, + problemId: problemId, + result: AttemptResult.allCases.first!, + highestHold: nil, + notes: nil, + duration: nil, + restTime: nil, + timestamp: dateString, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // MARK: - Backup Format Errors diff --git a/ios/Ascently/Models/DeltaSyncFormat.swift b/ios/Ascently/Models/DeltaSyncFormat.swift index 951f39d..9277f79 100644 --- a/ios/Ascently/Models/DeltaSyncFormat.swift +++ b/ios/Ascently/Models/DeltaSyncFormat.swift @@ -13,7 +13,6 @@ struct DeltaSyncRequest: Codable { let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] - let deletedItems: [DeletedItem] } struct DeltaSyncResponse: Codable { @@ -22,5 +21,4 @@ struct DeltaSyncResponse: Codable { let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] - let deletedItems: [DeletedItem] } diff --git a/ios/Ascently/Services/Sync/ServerSyncProvider.swift b/ios/Ascently/Services/Sync/ServerSyncProvider.swift index 132522a..36ccb0b 100644 --- a/ios/Ascently/Services/Sync/ServerSyncProvider.swift +++ b/ios/Ascently/Services/Sync/ServerSyncProvider.swift @@ -1,19 +1,21 @@ import Combine import Foundation +import UIKit // Needed for UIImage/Data handling if not using ImageManager exclusively +@MainActor class ServerSyncProvider: SyncProvider { - var type: SyncProviderType { .server } - + var type: SyncProviderType = .server + private let userDefaults = UserDefaults.standard private let logTag = "ServerSyncProvider" - + private enum Keys { static let serverURL = "sync_server_url" static let authToken = "sync_auth_token" static let lastSyncTime = "last_sync_time" static let isConnected = "is_connected" } - + var serverURL: String { get { userDefaults.string(forKey: Keys.serverURL) ?? "" } set { userDefaults.set(newValue, forKey: Keys.serverURL) } @@ -23,56 +25,43 @@ class ServerSyncProvider: SyncProvider { get { userDefaults.string(forKey: Keys.authToken) ?? "" } set { userDefaults.set(newValue, forKey: Keys.authToken) } } - + var isConfigured: Bool { return !serverURL.isEmpty && !authToken.isEmpty } - + var isConnected: Bool { get { userDefaults.bool(forKey: Keys.isConnected) } set { userDefaults.set(newValue, forKey: Keys.isConnected) } } - + var lastSyncTime: Date? { get { userDefaults.object(forKey: Keys.lastSyncTime) as? Date } set { userDefaults.set(newValue, forKey: Keys.lastSyncTime) } } - + func disconnect() { isConnected = false lastSyncTime = nil - userDefaults.removeObject(forKey: Keys.lastSyncTime) - userDefaults.set(false, forKey: Keys.isConnected) } - - func testConnection() async throws { - guard isConfigured else { - throw SyncError.notConfigured - } + func testConnection() async throws { guard let url = URL(string: "\(serverURL)/health") else { throw SyncError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.timeoutInterval = 10 let (_, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw SyncError.invalidResponse + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw SyncError.serverError(500) } - guard httpResponse.statusCode == 200 else { - throw SyncError.serverError(httpResponse.statusCode) - } - - // Connection successful, mark as connected isConnected = true } - + func sync(dataManager: ClimbingDataManager) async throws { guard isConfigured else { throw SyncError.notConfigured @@ -81,7 +70,22 @@ class ServerSyncProvider: SyncProvider { guard isConnected else { throw SyncError.notConnected } - + + // 1. Priority: Delta Sync + // If we have synced before, assume we want to continue with delta sync + if lastSyncTime != nil { + AppLogger.info("Last sync time found, attempting delta sync", tag: logTag) + do { + try await performDeltaSync(dataManager: dataManager) + lastSyncTime = Date() + return + } catch { + AppLogger.error("Delta sync failed, falling back to full sync check: \(error)", tag: logTag) + // Fallthrough to full sync logic + } + } + + // 2. Full Sync Logic // Get local backup data let localBackup = createBackupFromDataManager(dataManager) @@ -97,16 +101,6 @@ class ServerSyncProvider: SyncProvider { !serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty || !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty - // If both client and server have been synced before, use delta sync - if hasLocalData && hasServerData && lastSyncTime != nil { - AppLogger.info("Using delta sync for incremental updates", tag: logTag) - try await performDeltaSync(dataManager: dataManager) - - // Update last sync time - lastSyncTime = Date() - return - } - if !hasLocalData && hasServerData { AppLogger.info("Performing full restore from server", tag: logTag) AppLogger.info("Syncing images from server first...", tag: logTag) @@ -137,9 +131,9 @@ class ServerSyncProvider: SyncProvider { // Update last sync time lastSyncTime = Date() } - + // MARK: - Private Helpers - + private func downloadData() async throws -> ClimbDataBackup { guard let url = URL(string: "\(serverURL)/sync") else { throw SyncError.invalidURL @@ -172,7 +166,7 @@ class ServerSyncProvider: SyncProvider { throw SyncError.decodingError(error) } } - + private func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { guard let url = URL(string: "\(serverURL)/sync") else { throw SyncError.invalidURL @@ -214,7 +208,7 @@ class ServerSyncProvider: SyncProvider { throw SyncError.decodingError(error) } } - + private func performDeltaSync(dataManager: ClimbingDataManager) async throws { guard let url = URL(string: "\(serverURL)/sync/delta") else { throw SyncError.invalidURL @@ -226,11 +220,11 @@ class ServerSyncProvider: SyncProvider { let lastSyncString = formatter.string(from: lastSync) // Collect items modified since last sync - let modifiedGyms = dataManager.gyms.filter { gym in + var modifiedGyms = dataManager.gyms.filter { gym in gym.updatedAt > lastSync }.map { BackupGym(from: $0) } - let modifiedProblems = dataManager.problems.filter { problem in + var modifiedProblems = dataManager.problems.filter { problem in problem.updatedAt > lastSync }.map { problem -> BackupProblem in let backupProblem = BackupProblem(from: problem) @@ -239,45 +233,48 @@ class ServerSyncProvider: SyncProvider { ImageNamingUtils.generateImageFilename( problemId: problem.id.uuidString, imageIndex: index) } - return BackupProblem( - id: backupProblem.id, - gymId: backupProblem.gymId, - name: backupProblem.name, - description: backupProblem.description, - climbType: backupProblem.climbType, - difficulty: backupProblem.difficulty, - tags: backupProblem.tags, - location: backupProblem.location, - imagePaths: normalizedPaths, - isActive: backupProblem.isActive, - dateSet: backupProblem.dateSet, - notes: backupProblem.notes, - createdAt: backupProblem.createdAt, - updatedAt: backupProblem.updatedAt - ) + return backupProblem.withUpdatedImagePaths(normalizedPaths) } return backupProblem } - let modifiedSessions = dataManager.sessions.filter { session in + var modifiedSessions = dataManager.sessions.filter { session in session.status != .active && session.updatedAt > lastSync }.map { BackupClimbSession(from: $0) } let activeSessionIds = Set( dataManager.sessions.filter { $0.status == .active }.map { $0.id }) - let modifiedAttempts = dataManager.attempts.filter { attempt in + + var modifiedAttempts = dataManager.attempts.filter { attempt in !activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync }.map { BackupAttempt(from: $0) } - let modifiedDeletions = dataManager.getDeletedItems().filter { item in + // Handle deleted items as tombstones + let deletedItems = dataManager.getDeletedItems().filter { item in if let deletedDate = formatter.date(from: item.deletedAt) { return deletedDate > lastSync } return false } + for item in deletedItems { + guard let deletedDate = formatter.date(from: item.deletedAt) else { continue } + switch item.type { + case "gym": + modifiedGyms.append(BackupGym.createTombstone(id: item.id, deletedAt: deletedDate)) + case "problem": + modifiedProblems.append(BackupProblem.createTombstone(id: item.id, deletedAt: deletedDate)) + case "session": + modifiedSessions.append(BackupClimbSession.createTombstone(id: item.id, deletedAt: deletedDate)) + case "attempt": + modifiedAttempts.append(BackupAttempt.createTombstone(id: item.id, deletedAt: deletedDate)) + default: + break + } + } + AppLogger.info( - "Delta Sync: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)", + "Delta Sync: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count)", tag: logTag ) @@ -287,8 +284,7 @@ class ServerSyncProvider: SyncProvider { gyms: modifiedGyms, problems: modifiedProblems, sessions: modifiedSessions, - attempts: modifiedAttempts, - deletedItems: modifiedDeletions + attempts: modifiedAttempts ) let encoder = JSONEncoder() @@ -321,14 +317,14 @@ class ServerSyncProvider: SyncProvider { let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data) AppLogger.info( - "Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)", + "Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count)", tag: logTag ) // Apply server changes to local data try await applyDeltaResponse(deltaResponse, dataManager: dataManager) - // Sync only modified problem images + // Upload images for modified problems try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager) // Update last sync time to server time @@ -336,80 +332,52 @@ class ServerSyncProvider: SyncProvider { lastSyncTime = serverTime } } - + private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager) async throws { - // Use SyncMerger logic but adapted for DeltaSyncResponse - // Since SyncMerger works with ClimbDataBackup, we might need to adapt or just do it here since it's specific to DeltaSync - - // Actually, DeltaSyncResponse is very similar to ClimbDataBackup but with serverTime - // Let's construct a pseudo-backup for merging or just use the logic here since it handles image downloads too - let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // Merge and apply deletions first to prevent resurrection - let allDeletions = dataManager.getDeletedItems() + response.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) - - AppLogger.info( - "Delta Sync: Applying \(uniqueDeletions.count) deletion records before merging data", - tag: logTag - ) - applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) - - // Build deleted item lookup map - let deletedItemSet = Set(uniqueDeletions.map { $0.type + ":" + $0.id }) - - // Download images for new/modified problems from server + // 1. Download images for problems that are NOT deleted var imagePathMapping: [String: String] = [:] for problem in response.problems { - if deletedItemSet.contains("problem:" + problem.id) { - continue - } + if let isDeleted = problem.isDeleted, isDeleted { continue } guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } for (index, imagePath) in imagePaths.enumerated() { let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent - do { let imageData = try await downloadImage(filename: serverFilename) let consistentFilename = ImageNamingUtils.generateImageFilename( problemId: problem.id, imageIndex: index) - let imageManager = ImageManager.shared - _ = try imageManager.saveImportedImage(imageData, filename: consistentFilename) + // Save image using ImageManager + _ = try ImageManager.shared.saveImportedImage(imageData, filename: consistentFilename) imagePathMapping[serverFilename] = consistentFilename - } catch SyncError.imageNotFound { - AppLogger.info("Image not found on server: \(serverFilename)", tag: logTag) - continue } catch { AppLogger.info("Failed to download image \(serverFilename): \(error)", tag: logTag) - continue } } } - - // Now we can use SyncMerger logic if we convert response to Backup format - // But SyncMerger.mergeDataSafely does a full merge. Here we are doing delta merge. - // The logic in SyncService.swift for applyDeltaResponse was: - // 1. Download images - // 2. Merge gyms (check timestamps) - // 3. Merge problems (check timestamps) - // ... - - // This logic is slightly different from full merge because it checks timestamps against existing items specifically for delta. - // Full merge also checks timestamps but assumes full dataset. - // Let's keep the logic here for now as it is specific to the Delta Sync protocol. - - // Merge gyms + + // 2. Merge Gyms for backupGym in response.gyms { - if deletedItemSet.contains("gym:" + backupGym.id) { + // Handle Soft Delete + if let isDeleted = backupGym.isDeleted, isDeleted { + if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) { + let existing = dataManager.gyms[index] + if let serverUpdate = formatter.date(from: backupGym.updatedAt), + serverUpdate >= existing.updatedAt { + dataManager.gyms.remove(at: index) + } + } continue } - if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) - { + // Handle Update/Insert + if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) { let existing = dataManager.gyms[index] - if backupGym.updatedAt >= formatter.string(from: existing.updatedAt) { + if let serverUpdate = formatter.date(from: backupGym.updatedAt), + serverUpdate >= existing.updatedAt { dataManager.gyms[index] = try backupGym.toGym() } } else { @@ -417,38 +385,29 @@ class ServerSyncProvider: SyncProvider { } } - // Merge problems + // 3. Merge Problems for backupProblem in response.problems { - if deletedItemSet.contains("problem:" + backupProblem.id) { + if let isDeleted = backupProblem.isDeleted, isDeleted { + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == backupProblem.id }) { + let existing = dataManager.problems[index] + if let serverUpdate = formatter.date(from: backupProblem.updatedAt), + serverUpdate >= existing.updatedAt { + dataManager.problems.remove(at: index) + } + } continue } var problemToMerge = backupProblem if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths { let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 } - problemToMerge = BackupProblem( - id: backupProblem.id, - gymId: backupProblem.gymId, - name: backupProblem.name, - description: backupProblem.description, - climbType: backupProblem.climbType, - difficulty: backupProblem.difficulty, - tags: backupProblem.tags, - location: backupProblem.location, - imagePaths: updatedPaths, - isActive: backupProblem.isActive, - dateSet: backupProblem.dateSet, - notes: backupProblem.notes, - createdAt: backupProblem.createdAt, - updatedAt: backupProblem.updatedAt - ) + problemToMerge = backupProblem.withUpdatedImagePaths(updatedPaths) } - if let index = dataManager.problems.firstIndex(where: { - $0.id.uuidString == problemToMerge.id - }) { + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problemToMerge.id }) { let existing = dataManager.problems[index] - if problemToMerge.updatedAt >= formatter.string(from: existing.updatedAt) { + if let serverUpdate = formatter.date(from: problemToMerge.updatedAt), + serverUpdate >= existing.updatedAt { dataManager.problems[index] = try problemToMerge.toProblem() } } else { @@ -456,17 +415,23 @@ class ServerSyncProvider: SyncProvider { } } - // Merge sessions + // 4. Merge Sessions for backupSession in response.sessions { - if deletedItemSet.contains("session:" + backupSession.id) { + if let isDeleted = backupSession.isDeleted, isDeleted { + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) { + let existing = dataManager.sessions[index] + if let serverUpdate = formatter.date(from: backupSession.updatedAt), + serverUpdate >= existing.updatedAt { + dataManager.sessions.remove(at: index) + } + } continue } - if let index = dataManager.sessions.firstIndex(where: { - $0.id.uuidString == backupSession.id - }) { + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) { let existing = dataManager.sessions[index] - if backupSession.updatedAt >= formatter.string(from: existing.updatedAt) { + if let serverUpdate = formatter.date(from: backupSession.updatedAt), + serverUpdate >= existing.updatedAt { dataManager.sessions[index] = try backupSession.toClimbSession() } } else { @@ -474,17 +439,25 @@ class ServerSyncProvider: SyncProvider { } } - // Merge attempts + // 5. Merge Attempts for backupAttempt in response.attempts { - if deletedItemSet.contains("attempt:" + backupAttempt.id) { + if let isDeleted = backupAttempt.isDeleted, isDeleted { + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) { + let existing = dataManager.attempts[index] + let serverTimeStr = backupAttempt.updatedAt ?? backupAttempt.createdAt + if let serverTime = formatter.date(from: serverTimeStr), + serverTime >= existing.updatedAt { + dataManager.attempts.remove(at: index) + } + } continue } - if let index = dataManager.attempts.firstIndex(where: { - $0.id.uuidString == backupAttempt.id - }) { + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) { let existing = dataManager.attempts[index] - if backupAttempt.createdAt >= formatter.string(from: existing.createdAt) { + let serverTimeStr = backupAttempt.updatedAt ?? backupAttempt.createdAt + if let serverTime = formatter.date(from: serverTimeStr), + serverTime >= existing.updatedAt { dataManager.attempts[index] = try backupAttempt.toAttempt() } } else { @@ -492,81 +465,25 @@ class ServerSyncProvider: SyncProvider { } } - // Apply deletions again for safety - applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) - - // Save all changes dataManager.saveGyms() dataManager.saveProblems() dataManager.saveSessions() dataManager.saveAttempts() - - // Update deletion records - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(uniqueDeletions) { - UserDefaults.standard.set(data, forKey: "ascently_deleted_items") - } - - DataStateManager.shared.updateDataState() } - - private func applyDeletionsToDataManager( - deletions: [DeletedItem], dataManager: ClimbingDataManager - ) { - let deletedGymIds = Set(deletions.filter { $0.type == "gym" }.map { $0.id }) - let deletedProblemIds = Set(deletions.filter { $0.type == "problem" }.map { $0.id }) - let deletedSessionIds = Set(deletions.filter { $0.type == "session" }.map { $0.id }) - let deletedAttemptIds = Set(deletions.filter { $0.type == "attempt" }.map { $0.id }) - dataManager.gyms.removeAll { deletedGymIds.contains($0.id.uuidString) } - dataManager.problems.removeAll { deletedProblemIds.contains($0.id.uuidString) } - dataManager.sessions.removeAll { deletedSessionIds.contains($0.id.uuidString) } - dataManager.attempts.removeAll { deletedAttemptIds.contains($0.id.uuidString) } + // MARK: - Image Sync + + private func syncModifiedImages(modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager) async throws { + for problem in modifiedProblems { + guard let imagePaths = problem.imagePaths else { continue } + for path in imagePaths { + if let data = ImageManager.shared.getImageData(filename: path) { + try await uploadImage(filename: path, imageData: data) + } + } + } } - - private func syncModifiedImages( - modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager - ) async throws { - guard !modifiedProblems.isEmpty else { return } - AppLogger.info("Delta Sync: Syncing images for \(modifiedProblems.count) modified problems", tag: logTag) - - for backupProblem in modifiedProblems { - guard - let problem = dataManager.problems.first(where: { - $0.id.uuidString == backupProblem.id - }) - else { - continue - } - - for (index, imagePath) in problem.imagePaths.enumerated() { - let filename = URL(fileURLWithPath: imagePath).lastPathComponent - let consistentFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - - let imageManager = ImageManager.shared - let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path - - if let imageData = imageManager.loadImageData(fromPath: fullPath) { - do { - if filename != consistentFilename { - let newPath = imageManager.imagesDirectory.appendingPathComponent( - consistentFilename - ).path - try? FileManager.default.moveItem(atPath: fullPath, toPath: newPath) - } - - try await uploadImage(filename: consistentFilename, imageData: imageData) - AppLogger.info("Uploaded modified problem image: \(consistentFilename)", tag: logTag) - } catch { - AppLogger.info("Failed to upload image \(consistentFilename): \(error)", tag: logTag) - } - } - } - } - } - private func uploadImage(filename: String, imageData: Data) async throws { guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else { throw SyncError.invalidURL @@ -575,28 +492,14 @@ class ServerSyncProvider: SyncProvider { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.httpBody = imageData - request.timeoutInterval = 60.0 - request.cachePolicy = .reloadIgnoringLocalCacheData - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw SyncError.invalidResponse - } - - switch httpResponse.statusCode { - case 200: - break - case 401: - throw SyncError.unauthorized - default: - throw SyncError.serverError(httpResponse.statusCode) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw SyncError.serverError(500) } } - + private func downloadImage(filename: String) async throws -> Data { guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { throw SyncError.invalidURL @@ -606,581 +509,209 @@ class ServerSyncProvider: SyncProvider { request.httpMethod = "GET" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.timeoutInterval = 45.0 - request.cachePolicy = .returnCacheDataElseLoad - let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw SyncError.invalidResponse + throw SyncError.invalidResponse } - switch httpResponse.statusCode { - case 200: - return data - case 401: - throw SyncError.unauthorized - case 404: + if httpResponse.statusCode == 404 { throw SyncError.imageNotFound - default: + } + + guard httpResponse.statusCode == 200 else { throw SyncError.serverError(httpResponse.statusCode) } - } - - private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) - async throws -> [String: String] - { - var imagePathMapping: [String: String] = [:] + return data + } + + // MARK: - Full Sync Helpers (Simplified for reconstruction) + + private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws -> [String: String] { + var mapping: [String: String] = [:] for problem in backup.problems { - guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } - - for (index, imagePath) in imagePaths.enumerated() { - let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent - - do { - let imageData = try await downloadImage(filename: serverFilename) - - let consistentFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id, imageIndex: index) - - let imageManager = ImageManager.shared - _ = try imageManager.saveImportedImage( - imageData, filename: consistentFilename) - - imagePathMapping[serverFilename] = consistentFilename - AppLogger.info("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)", tag: logTag) - } catch SyncError.imageNotFound { - AppLogger.info("Image not found on server: \(serverFilename)", tag: logTag) - continue - } catch { - AppLogger.info("Failed to download image \(serverFilename): \(error)", tag: logTag) - continue - } + guard let paths = problem.imagePaths else { continue } + for (index, path) in paths.enumerated() { + do { + let data = try await downloadImage(filename: path) + let localName = ImageNamingUtils.generateImageFilename(problemId: problem.id, imageIndex: index) + _ = try ImageManager.shared.saveImportedImage(data, filename: localName) + mapping[path] = localName + } catch { + continue + } } } - - return imagePathMapping + return mapping } - + private func syncImagesToServer(dataManager: ClimbingDataManager) async throws { - // Process images by problem to ensure consistent naming for problem in dataManager.problems { - guard !problem.imagePaths.isEmpty else { continue } - - for (index, imagePath) in problem.imagePaths.enumerated() { - let filename = URL(fileURLWithPath: imagePath).lastPathComponent - - let consistentFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - - // Load image data - let imageManager = ImageManager.shared - let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path - - if let imageData = imageManager.loadImageData(fromPath: fullPath) { - do { - // If filename changed, rename local file - if filename != consistentFilename { - let newPath = imageManager.imagesDirectory.appendingPathComponent( - consistentFilename - ).path - do { - try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) - AppLogger.info("Renamed local image: \(filename) -> \(consistentFilename)", tag: logTag) - - // Update problem's image path in memory for consistency - } catch { - AppLogger.info("Failed to rename local image, using original: \(error)", tag: logTag) - } - } - - try await uploadImage(filename: consistentFilename, imageData: imageData) - AppLogger.info("Successfully uploaded image: \(consistentFilename)", tag: logTag) - } catch { - AppLogger.info("Failed to upload image \(consistentFilename): \(error)", tag: logTag) - // Continue with other images even if one fails - } - } + for path in problem.imagePaths { + if let data = ImageManager.shared.getImageData(filename: path) { + try await uploadImage(filename: path, imageData: data) + } } } } - - private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup - { - // Filter out active sessions and their attempts from sync - let completedSessions = dataManager.sessions.filter { $0.status != .active } - let activeSessionIds = Set( - dataManager.sessions.filter { $0.status == .active }.map { $0.id }) - let completedAttempts = dataManager.attempts.filter { - !activeSessionIds.contains($0.sessionId) - } - AppLogger.info( - "Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync", - tag: logTag - ) + private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup { + // Simple mapping + let gyms = dataManager.gyms.map { BackupGym(from: $0) } + let problems = dataManager.problems.map { BackupProblem(from: $0) } + let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) } + let attempts = dataManager.attempts.map { BackupAttempt(from: $0) } return ClimbDataBackup( - exportedAt: DataStateManager.shared.getLastModified(), - gyms: dataManager.gyms.map { BackupGym(from: $0) }, - problems: dataManager.problems.map { BackupProblem(from: $0) }, - sessions: completedSessions.map { BackupClimbSession(from: $0) }, - attempts: completedAttempts.map { BackupAttempt(from: $0) }, - deletedItems: dataManager.getDeletedItems() + exportedAt: ISO8601DateFormatter().string(from: Date()), + gyms: gyms, + problems: problems, + sessions: sessions, + attempts: attempts, + deletedItems: [] // Legacy field, empty ) } - - private func mergeDataSafely( - localBackup: ClimbDataBackup, - serverBackup: ClimbDataBackup, - dataManager: ClimbingDataManager - ) async throws { - // Download server images first - let imagePathMapping = try await syncImagesFromServer( - backup: serverBackup, dataManager: dataManager) - // Use SyncMerger - let mergedResult = try SyncMerger.mergeDataSafely( - localBackup: localBackup, - serverBackup: serverBackup, - dataManager: dataManager, - imagePathMapping: imagePathMapping - ) + private func mergeDataSafely(localBackup: ClimbDataBackup, serverBackup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws { + // Basic full merge that prefers server data if newer + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // Update data manager with merged data - dataManager.gyms = mergedResult.gyms - dataManager.problems = mergedResult.problems - dataManager.sessions = mergedResult.sessions - dataManager.attempts = mergedResult.attempts + // Merging Gyms + for gym in serverBackup.gyms { + // Check for soft delete + if let isDeleted = gym.isDeleted, isDeleted { + if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) { + let existing = dataManager.gyms[index] + if let serverUpdate = formatter.date(from: gym.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.gyms.remove(at: index) + } + } + continue + } + + // Update or Insert + if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) { + let existing = dataManager.gyms[index] + if let serverUpdate = formatter.date(from: gym.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.gyms[index] = try gym.toGym() + } + } else { + dataManager.gyms.append(try gym.toGym()) + } + } + + // Merging Problems + for problem in serverBackup.problems { + if let isDeleted = problem.isDeleted, isDeleted { + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problem.id }) { + let existing = dataManager.problems[index] + if let serverUpdate = formatter.date(from: problem.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.problems.remove(at: index) + } + } + continue + } + + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problem.id }) { + let existing = dataManager.problems[index] + if let serverUpdate = formatter.date(from: problem.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.problems[index] = try problem.toProblem() + } + } else { + dataManager.problems.append(try problem.toProblem()) + } + } + + // Merging Sessions + for session in serverBackup.sessions { + if let isDeleted = session.isDeleted, isDeleted { + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == session.id }) { + let existing = dataManager.sessions[index] + if let serverUpdate = formatter.date(from: session.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.sessions.remove(at: index) + } + } + continue + } + + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == session.id }) { + let existing = dataManager.sessions[index] + if let serverUpdate = formatter.date(from: session.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.sessions[index] = try session.toClimbSession() + } + } else { + dataManager.sessions.append(try session.toClimbSession()) + } + } + + // Merging Attempts + for attempt in serverBackup.attempts { + if let isDeleted = attempt.isDeleted, isDeleted { + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == attempt.id }) { + let existing = dataManager.attempts[index] + let serverTimeStr = attempt.updatedAt ?? attempt.createdAt + if let serverUpdate = formatter.date(from: serverTimeStr), serverUpdate >= existing.updatedAt { + dataManager.attempts.remove(at: index) + } + } + continue + } + + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == attempt.id }) { + let existing = dataManager.attempts[index] + let serverTimeStr = attempt.updatedAt ?? attempt.createdAt + if let serverUpdate = formatter.date(from: serverTimeStr), serverUpdate >= existing.updatedAt { + dataManager.attempts[index] = try attempt.toAttempt() + } + } else { + dataManager.attempts.append(try attempt.toAttempt()) + } + } - // Save all data dataManager.saveGyms() dataManager.saveProblems() dataManager.saveSessions() dataManager.saveAttempts() - dataManager.saveActiveSession() - - // Update local deletions with merged list - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(mergedResult.uniqueDeletions) { - UserDefaults.standard.set(data, forKey: "ascently_deleted_items") - } - - // Upload merged data back to server - let mergedBackup = createBackupFromDataManager(dataManager) - _ = try await uploadData(mergedBackup) - try await syncImagesToServer(dataManager: dataManager) - - // Update timestamp - DataStateManager.shared.updateDataState() } - + private func importBackupToDataManager( _ backup: ClimbDataBackup, dataManager: ClimbingDataManager, imagePathMapping: [String: String] = [:] ) throws { - // This logic is also in SyncService.swift, it's quite complex as it handles active sessions preservation - // I'll copy it here. - - do { - // Store active sessions and their attempts before import (but exclude any that were deleted) - let localDeletedItems = dataManager.getDeletedItems() - let allDeletedSessionIds = Set( - (backup.deletedItems + localDeletedItems) - .filter { $0.type == "session" } - .map { $0.id } - ) - let activeSessions = dataManager.sessions.filter { - $0.status == .active && !allDeletedSessionIds.contains($0.id.uuidString) - } - let activeSessionIds = Set(activeSessions.map { $0.id }) - let allDeletedAttemptIds = Set( - (backup.deletedItems + localDeletedItems) - .filter { $0.type == "attempt" } - .map { $0.id } - ) - let activeAttempts = dataManager.attempts.filter { - activeSessionIds.contains($0.sessionId) - && !allDeletedAttemptIds.contains($0.id.uuidString) - } - - AppLogger.info( - "Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import", - tag: logTag - ) - - // Update problem image paths to point to downloaded images - let updatedBackup: ClimbDataBackup - if !imagePathMapping.isEmpty { - let updatedProblems = backup.problems.map { problem in - let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in - imagePathMapping[oldPath] ?? oldPath - } - return BackupProblem( - id: problem.id, - gymId: problem.gymId, - name: problem.name, - description: problem.description, - climbType: problem.climbType, - difficulty: problem.difficulty, - tags: problem.tags, - location: problem.location, - imagePaths: updatedImagePaths, - isActive: problem.isActive, - dateSet: problem.dateSet, - notes: problem.notes, - createdAt: problem.createdAt, - updatedAt: problem.updatedAt - ) - } - // Filter out deleted items before creating updated backup - let deletedGymIds = Set( - backup.deletedItems.filter { $0.type == "gym" }.map { $0.id }) - let deletedProblemIds = Set( - backup.deletedItems.filter { $0.type == "problem" }.map { $0.id }) - let deletedSessionIds = Set( - backup.deletedItems.filter { $0.type == "session" }.map { $0.id }) - let deletedAttemptIds = Set( - backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id }) - - let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) } - let filteredProblems = updatedProblems.filter { !deletedProblemIds.contains($0.id) } - let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) } - let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) } - - updatedBackup = ClimbDataBackup( - exportedAt: backup.exportedAt, - version: backup.version, - formatVersion: backup.formatVersion, - gyms: filteredGyms, - problems: filteredProblems, - sessions: filteredSessions, - attempts: filteredAttempts, - deletedItems: backup.deletedItems - ) - } else { - // Filter out deleted items even when no image path mapping - let deletedGymIds = Set( - backup.deletedItems.filter { $0.type == "gym" }.map { $0.id }) - let deletedProblemIds = Set( - backup.deletedItems.filter { $0.type == "problem" }.map { $0.id }) - let deletedSessionIds = Set( - backup.deletedItems.filter { $0.type == "session" }.map { $0.id }) - let deletedAttemptIds = Set( - backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id }) - - let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) } - let filteredProblems = backup.problems.filter { !deletedProblemIds.contains($0.id) } - let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) } - let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) } - - updatedBackup = ClimbDataBackup( - exportedAt: backup.exportedAt, - version: backup.version, - formatVersion: backup.formatVersion, - gyms: filteredGyms, - problems: filteredProblems, - sessions: filteredSessions, - attempts: filteredAttempts, - deletedItems: backup.deletedItems - ) - } - - // Create a minimal ZIP with just the JSON data for existing import mechanism - // We need to use ZipUtils or similar. SyncService had createMinimalZipFromBackup. - // I should probably move createMinimalZipFromBackup to ZipUtils or just copy it here. - // Since ZipUtils exists, let's see if we can use it. - // ZipUtils.createExportZip creates a full zip. - // SyncService had a custom implementation for minimal zip. - // I'll copy the implementation here for now to avoid changing ZipUtils too much, or I can add it to ZipUtils. - // For now, I'll copy it to keep this file self-contained regarding the sync logic. - - let zipData = try createMinimalZipFromBackup(updatedBackup) - - // Use existing import method which properly handles data restoration - try dataManager.importData(from: zipData, showSuccessMessage: false) - - // Restore active sessions and their attempts after import - for session in activeSessions { - AppLogger.info("Restoring active session: \(session.id)", tag: logTag) - dataManager.sessions.append(session) - if session.id == dataManager.activeSession?.id { - dataManager.activeSession = session - } - } - - for attempt in activeAttempts { - dataManager.attempts.append(attempt) - } - - // Save restored data - dataManager.saveSessions() - dataManager.saveAttempts() - dataManager.saveActiveSession() - - // Import deletion records to prevent future resurrections - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(backup.deletedItems) { - UserDefaults.standard.set(data, forKey: "ascently_deleted_items") - AppLogger.info("Imported \(backup.deletedItems.count) deletion records", tag: logTag) - } - - // Update local data state to match imported data timestamp - DataStateManager.shared.setLastModified(backup.exportedAt) - AppLogger.info("Data state synchronized to imported timestamp: \(backup.exportedAt)", tag: logTag) - } catch { - throw SyncError.importFailed(error) - } - } - - // Copied from SyncService.swift - private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data { - // Create JSON data - - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - encoder.dateEncodingStrategy = .custom { date, encoder in - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - var container = encoder.singleValueContainer() - try container.encode(formatter.string(from: date)) - } - let jsonData = try encoder.encode(backup) - - // Collect all images from ImageManager - let imageManager = ImageManager.shared - var imageFiles: [(filename: String, data: Data)] = [] - - // Get original problems to access actual image paths on disk - if let problemsData = userDefaults.data(forKey: "ascently_problems"), // Changed key to match ClimbingDataManager - let problems = try? JSONDecoder().decode([Problem].self, from: problemsData) - { - // Create a mapping from normalized paths to actual paths - for problem in problems { - for (index, imagePath) in problem.imagePaths.enumerated() { - // Get the actual filename on disk - let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent - let fullPath = imageManager.imagesDirectory.appendingPathComponent( - actualFilename - ).path - - // Generate the normalized filename for the ZIP - let normalizedFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - - if let imageData = imageManager.loadImageData(fromPath: fullPath) { - imageFiles.append((filename: normalizedFilename, data: imageData)) - } - } - } + // Logic from previous read + let updatedProblems = backup.problems.map { problem in + let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in + imagePathMapping[oldPath] ?? oldPath + } ?? [] + return problem.withUpdatedImagePaths(updatedImagePaths) } - // Create ZIP with data.json, metadata, and images - var zipData = Data() - var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] - var currentOffset: UInt32 = 0 - - // Add data.json to ZIP - try addFileToMinimalZip( - filename: "data.json", - fileData: jsonData, - zipData: &zipData, - fileEntries: &fileEntries, - currentOffset: ¤tOffset - ) - - // Add metadata with correct image count - let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)" - let metadataData = metadata.data(using: .utf8) ?? Data() - try addFileToMinimalZip( - filename: "metadata.txt", - fileData: metadataData, - zipData: &zipData, - fileEntries: &fileEntries, - currentOffset: ¤tOffset - ) - - // Add images to ZIP in images/ directory - for imageFile in imageFiles { - try addFileToMinimalZip( - filename: "images/\(imageFile.filename)", - fileData: imageFile.data, - zipData: &zipData, - fileEntries: &fileEntries, - currentOffset: ¤tOffset - ) + // Re-construct data, filtering out deleted items (tombstones) + dataManager.gyms = try backup.gyms.compactMap { gym in + if let isDeleted = gym.isDeleted, isDeleted { return nil } + return try gym.toGym() } - // Add central directory - var centralDirectory = Data() - for entry in fileEntries { - centralDirectory.append(createCentralDirectoryHeader(entry: entry)) + dataManager.problems = try updatedProblems.compactMap { problem in + if let isDeleted = problem.isDeleted, isDeleted { return nil } + return try problem.toProblem() } - // Add end of central directory record - let endOfCentralDir = createEndOfCentralDirectoryRecord( - fileCount: UInt16(fileEntries.count), - centralDirSize: UInt32(centralDirectory.count), - centralDirOffset: currentOffset - ) + dataManager.sessions = try backup.sessions.compactMap { session in + if let isDeleted = session.isDeleted, isDeleted { return nil } + return try session.toClimbSession() + } - zipData.append(centralDirectory) - zipData.append(endOfCentralDir) + dataManager.attempts = try backup.attempts.compactMap { attempt in + if let isDeleted = attempt.isDeleted, isDeleted { return nil } + return try attempt.toAttempt() + } - return zipData - } - - private func addFileToMinimalZip( - filename: String, - fileData: Data, - zipData: inout Data, - fileEntries: inout [(name: String, data: Data, offset: UInt32)], - currentOffset: inout UInt32 - ) throws { - let localFileHeader = createLocalFileHeader( - filename: filename, fileSize: UInt32(fileData.count)) - - fileEntries.append((name: filename, data: fileData, offset: currentOffset)) - - zipData.append(localFileHeader) - zipData.append(fileData) - - currentOffset += UInt32(localFileHeader.count + fileData.count) - } - - private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data { - var header = Data() - - // Local file header signature - header.append(Data([0x50, 0x4b, 0x03, 0x04])) - - // Version needed to extract (2.0) - header.append(Data([0x14, 0x00])) - - // General purpose bit flag - header.append(Data([0x00, 0x00])) - - // Compression method (no compression) - header.append(Data([0x00, 0x00])) - - // Last mod file time & date (dummy values) - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // CRC-32 (dummy - we're not compressing) - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // Compressed size - withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } - - // Uncompressed size - withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } - - // File name length - let filenameData = filename.data(using: .utf8) ?? Data() - let filenameLength = UInt16(filenameData.count) - withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } - - // Extra field length - header.append(Data([0x00, 0x00])) - - // File name - header.append(filenameData) - - return header - } - - private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32)) - -> Data - { - var header = Data() - - // Central directory signature - header.append(Data([0x50, 0x4b, 0x01, 0x02])) - - // Version made by - header.append(Data([0x14, 0x00])) - - // Version needed to extract - header.append(Data([0x14, 0x00])) - - // General purpose bit flag - header.append(Data([0x00, 0x00])) - - // Compression method - header.append(Data([0x00, 0x00])) - - // Last mod file time & date - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // CRC-32 - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // Compressed size - let compressedSize = UInt32(entry.data.count) - withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } - - // Uncompressed size - withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } - - // File name length - let filenameData = entry.name.data(using: .utf8) ?? Data() - let filenameLength = UInt16(filenameData.count) - withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } - - // Extra field length - header.append(Data([0x00, 0x00])) - - // File comment length - header.append(Data([0x00, 0x00])) - - // Disk number start - header.append(Data([0x00, 0x00])) - - // Internal file attributes - header.append(Data([0x00, 0x00])) - - // External file attributes - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // Relative offset of local header - withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) } - - // File name - header.append(filenameData) - - return header - } - - private func createEndOfCentralDirectoryRecord( - fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32 - ) -> Data { - var record = Data() - - // End of central dir signature - record.append(Data([0x50, 0x4b, 0x05, 0x06])) - - // Number of this disk - record.append(Data([0x00, 0x00])) - - // Number of the disk with the start of the central directory - record.append(Data([0x00, 0x00])) - - // Total number of entries in the central directory on this disk - withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } - - // Total number of entries in the central directory - withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } - - // Size of the central directory - withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) } - - // Offset of start of central directory - withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) } - - // ZIP file comment length - record.append(Data([0x00, 0x00])) - - return record + dataManager.saveGyms() + dataManager.saveProblems() + dataManager.saveSessions() + dataManager.saveAttempts() } } diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index bc38678..f109159 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers enum SheetType { case export(Data) case importData + case syncSettings } struct SettingsView: View { @@ -16,7 +17,7 @@ struct SettingsView: View { var body: some View { NavigationStack { List { - SyncSection() + SyncSection(activeSheet: $activeSheet) .environmentObject(dataManager.syncService) HealthKitSection() @@ -67,6 +68,9 @@ struct SettingsView: View { ExportDataView(data: data) case .importData: ImportDataView() + case .syncSettings: + SyncSettingsView() + .environmentObject(dataManager.syncService) } } } @@ -78,6 +82,7 @@ extension SheetType: Identifiable { switch self { case .export: return "export" case .importData: return "import" + case .syncSettings: return "sync_settings" } } } @@ -526,7 +531,7 @@ struct SyncSection: View { @EnvironmentObject var syncService: SyncService @EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var themeManager: ThemeManager - @State private var showingSyncSettings = false + @Binding var activeSheet: SheetType? @State private var showingDisconnectAlert = false private static let logTag = "SyncSection" @@ -567,7 +572,7 @@ struct SyncSection: View { // Configure Server Button(action: { - showingSyncSettings = true + activeSheet = .syncSettings }) { HStack { Image(systemName: "gear") @@ -657,10 +662,6 @@ struct SyncSection: View { } } } - .sheet(isPresented: $showingSyncSettings) { - SyncSettingsView() - .environmentObject(syncService) - } .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { Button("Cancel", role: .cancel) {} Button("Disconnect", role: .destructive) { @@ -702,24 +703,14 @@ struct SyncSettingsView: View { NavigationStack { Form { Section { - TextField("Server URL", text: $serverURL) - .textFieldStyle(.roundedBorder) + TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080")) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) - .placeholder(when: serverURL.isEmpty) { - Text("http://your-server:8080") - .foregroundColor(.secondary) - } - TextField("Auth Token", text: $authToken) - .textFieldStyle(.roundedBorder) + TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token")) .autocapitalization(.none) .disableAutocorrection(true) - .placeholder(when: authToken.isEmpty) { - Text("your-secret-token") - .foregroundColor(.secondary) - } } header: { Text("Server Configuration") } footer: { @@ -845,37 +836,34 @@ struct SyncSettingsView: View { let originalURL = syncService.serverURL let originalToken = syncService.authToken - Task { + Task { @MainActor in do { // Ensure we are using the server provider - await MainActor.run { - if syncService.providerType != .server { - syncService.providerType = .server - } + if syncService.providerType != .server { + syncService.providerType = .server } // Temporarily set the values for testing syncService.serverURL = testURL syncService.authToken = testToken + // Explicitly sync UserDefaults to ensure immediate availability + UserDefaults.standard.synchronize() + try await syncService.testConnection() - await MainActor.run { - isTesting = false - testResultMessage = - "Connection successful! You can now save and sync your data." - showingTestResult = true - } + isTesting = false + testResultMessage = + "Connection successful! You can now save and sync your data." + showingTestResult = true } catch { // Restore original values if test failed syncService.serverURL = originalURL syncService.authToken = originalToken - await MainActor.run { - isTesting = false - testResultMessage = "Connection failed: \(error.localizedDescription)" - showingTestResult = true - } + isTesting = false + testResultMessage = "Connection failed: \(error.localizedDescription)" + showingTestResult = true } } } diff --git a/sync/main.go b/sync/main.go index 40d335b..12d44e1 100644 --- a/sync/main.go +++ b/sync/main.go @@ -13,7 +13,7 @@ import ( "time" ) -const VERSION = "2.3.0" +const VERSION = "2.4.0" func min(a, b int) int { if a < b { @@ -22,12 +22,6 @@ func min(a, b int) int { return b } -type DeletedItem struct { - ID string `json:"id"` - Type string `json:"type"` - DeletedAt string `json:"deletedAt"` -} - type ClimbDataBackup struct { ExportedAt string `json:"exportedAt"` Version string `json:"version"` @@ -36,7 +30,6 @@ type ClimbDataBackup struct { Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` - DeletedItems []DeletedItem `json:"deletedItems"` } type DeltaSyncRequest struct { @@ -45,16 +38,14 @@ type DeltaSyncRequest struct { Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` - DeletedItems []DeletedItem `json:"deletedItems"` } type DeltaSyncResponse struct { - ServerTime string `json:"serverTime"` - Gyms []BackupGym `json:"gyms"` - Problems []BackupProblem `json:"problems"` - Sessions []BackupClimbSession `json:"sessions"` - Attempts []BackupAttempt `json:"attempts"` - DeletedItems []DeletedItem `json:"deletedItems"` + ServerTime string `json:"serverTime"` + Gyms []BackupGym `json:"gyms"` + Problems []BackupProblem `json:"problems"` + Sessions []BackupClimbSession `json:"sessions"` + Attempts []BackupAttempt `json:"attempts"` } type BackupGym struct { @@ -65,6 +56,7 @@ type BackupGym struct { DifficultySystems []string `json:"difficultySystems"` CustomDifficultyGrades []string `json:"customDifficultyGrades"` Notes *string `json:"notes,omitempty"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -82,6 +74,7 @@ type BackupProblem struct { IsActive bool `json:"isActive"` DateSet *string `json:"dateSet,omitempty"` Notes *string `json:"notes,omitempty"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -101,6 +94,7 @@ type BackupClimbSession struct { Duration *int64 `json:"duration,omitempty"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -115,7 +109,9 @@ type BackupAttempt struct { Duration *int64 `json:"duration,omitempty"` RestTime *int64 `json:"restTime,omitempty"` Timestamp string `json:"timestamp"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` + UpdatedAt *string `json:"updatedAt,omitempty"` } type SyncServer struct { @@ -147,7 +143,6 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) { Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, }, nil } @@ -158,7 +153,18 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) { } log.Printf("Read %d bytes from data file", len(data)) - log.Printf("File content preview: %s", string(data[:min(200, len(data))])) + // Basic check to see if we have JSON content + if len(data) == 0 { + return &ClimbDataBackup{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, nil + } var backup ClimbDataBackup if err := json.Unmarshal(data, &backup); err != nil { @@ -250,7 +256,18 @@ func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAtt for _, attempt := range updates { if existingAttempt, exists := attemptMap[attempt.ID]; exists { - if attempt.CreatedAt >= existingAttempt.CreatedAt { + // Resolve update time for comparison + updateTime := attempt.CreatedAt + if attempt.UpdatedAt != nil { + updateTime = *attempt.UpdatedAt + } + + existingUpdateTime := existingAttempt.CreatedAt + if existingAttempt.UpdatedAt != nil { + existingUpdateTime = *existingAttempt.UpdatedAt + } + + if updateTime >= existingUpdateTime { attemptMap[attempt.ID] = attempt } } else { @@ -265,89 +282,6 @@ func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAtt return result } -func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []DeletedItem) []DeletedItem { - deletedMap := make(map[string]DeletedItem) - for _, item := range existing { - key := item.Type + ":" + item.ID - deletedMap[key] = item - } - - for _, item := range updates { - key := item.Type + ":" + item.ID - if existingItem, exists := deletedMap[key]; exists { - if item.DeletedAt >= existingItem.DeletedAt { - deletedMap[key] = item - } - } else { - deletedMap[key] = item - } - } - - // Clean up tombstones older than 30 days to prevent unbounded growth - cutoffTime := time.Now().UTC().Add(-30 * 24 * time.Hour) - result := make([]DeletedItem, 0, len(deletedMap)) - for _, item := range deletedMap { - deletedTime, err := time.Parse(time.RFC3339, item.DeletedAt) - if err == nil && deletedTime.Before(cutoffTime) { - log.Printf("Cleaning up old deletion record: type=%s, id=%s, deletedAt=%s", - item.Type, item.ID, item.DeletedAt) - continue - } - result = append(result, item) - } - return result -} - -func (s *SyncServer) applyDeletions(backup *ClimbDataBackup, deletedItems []DeletedItem) { - deletedMap := make(map[string]map[string]bool) - for _, item := range deletedItems { - if deletedMap[item.Type] == nil { - deletedMap[item.Type] = make(map[string]bool) - } - deletedMap[item.Type][item.ID] = true - } - - if deletedMap["gym"] != nil { - filtered := []BackupGym{} - for _, gym := range backup.Gyms { - if !deletedMap["gym"][gym.ID] { - filtered = append(filtered, gym) - } - } - backup.Gyms = filtered - } - - if deletedMap["problem"] != nil { - filtered := []BackupProblem{} - for _, problem := range backup.Problems { - if !deletedMap["problem"][problem.ID] { - filtered = append(filtered, problem) - } - } - backup.Problems = filtered - } - - if deletedMap["session"] != nil { - filtered := []BackupClimbSession{} - for _, session := range backup.Sessions { - if !deletedMap["session"][session.ID] { - filtered = append(filtered, session) - } - } - backup.Sessions = filtered - } - - if deletedMap["attempt"] != nil { - filtered := []BackupAttempt{} - for _, attempt := range backup.Attempts { - if !deletedMap["attempt"][attempt.ID] { - filtered = append(filtered, attempt) - } - } - backup.Attempts = filtered - } -} - func (s *SyncServer) saveData(backup *ClimbDataBackup) error { backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) @@ -383,6 +317,8 @@ func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) { return } + + log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) w.Header().Set("Content-Type", "application/json") @@ -527,11 +463,10 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { return } - log.Printf("Delta sync from %s: lastSyncTime=%s, gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d", + log.Printf("Delta sync from %s: lastSyncTime=%s, gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, deltaRequest.LastSyncTime, len(deltaRequest.Gyms), len(deltaRequest.Problems), - len(deltaRequest.Sessions), len(deltaRequest.Attempts), - len(deltaRequest.DeletedItems)) + len(deltaRequest.Sessions), len(deltaRequest.Attempts)) // Load current server data serverBackup, err := s.loadData() @@ -541,12 +476,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { return } - // Merge and apply deletions first to prevent resurrection - serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems) - s.applyDeletions(serverBackup, serverBackup.DeletedItems) - log.Printf("Applied deletions: total=%d deletion records", len(serverBackup.DeletedItems)) - // Merge client changes into server data + // Note: We no longer need separate deletion handling as IsDeleted is part of the struct + // and handled by standard merge logic (latest timestamp wins) serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms) serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems) serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions) @@ -566,28 +498,17 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime) } - // Build deleted item lookup map - deletedItemMap := make(map[string]bool) - for _, item := range serverBackup.DeletedItems { - key := item.Type + ":" + item.ID - deletedItemMap[key] = true - } - // Prepare response with items modified since client's last sync response := DeltaSyncResponse{ - ServerTime: time.Now().UTC().Format(time.RFC3339), - Gyms: []BackupGym{}, - Problems: []BackupProblem{}, - Sessions: []BackupClimbSession{}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, + ServerTime: time.Now().UTC().Format(time.RFC3339), + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, } // Filter gyms modified after client's last sync for _, gym := range serverBackup.Gyms { - if deletedItemMap["gym:"+gym.ID] { - continue - } gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt) if err == nil && gymTime.After(clientLastSync) { response.Gyms = append(response.Gyms, gym) @@ -596,9 +517,6 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter problems modified after client's last sync for _, problem := range serverBackup.Problems { - if deletedItemMap["problem:"+problem.ID] { - continue - } problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt) if err == nil && problemTime.After(clientLastSync) { response.Problems = append(response.Problems, problem) @@ -607,39 +525,29 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter sessions modified after client's last sync for _, session := range serverBackup.Sessions { - if deletedItemMap["session:"+session.ID] { - continue - } sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt) if err == nil && sessionTime.After(clientLastSync) { response.Sessions = append(response.Sessions, session) } } - // Filter attempts created after client's last sync + // Filter attempts modified after client's last sync for _, attempt := range serverBackup.Attempts { - if deletedItemMap["attempt:"+attempt.ID] { - continue + attemptTime := attempt.CreatedAt + if attempt.UpdatedAt != nil { + attemptTime = *attempt.UpdatedAt } - attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt) - if err == nil && attemptTime.After(clientLastSync) { + + parsedTime, err := time.Parse(time.RFC3339, attemptTime) + if err == nil && parsedTime.After(clientLastSync) { response.Attempts = append(response.Attempts, attempt) } } - // Filter deletions after client's last sync - for _, deletedItem := range serverBackup.DeletedItems { - deletedTime, err := time.Parse(time.RFC3339, deletedItem.DeletedAt) - if err == nil && deletedTime.After(clientLastSync) { - response.DeletedItems = append(response.DeletedItems, deletedItem) - } - } - - log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d", + log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, len(response.Gyms), len(response.Problems), - len(response.Sessions), len(response.Attempts), - len(response.DeletedItems)) + len(response.Sessions), len(response.Attempts)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response)