Zc*EO9CzkmdKsSSrVJ+_|2G}S8G#HO*7^bu>JD8M`zxnvas^G%S6`nlof~s1_d|ZT8;<~2`o(;4)P`fwtEs`$cVU~
zQv~cf2qd1Ik|O$_d|5jQrORJfq|rdpwq|x_r=!D^-HOXTu5}rvW|r_lO3Q_!&Cnf*
zjO-SKv~x)RUH9|hZC9t(`Qyf2x5DnQ{dXPP_y>*mi$BKPWn4&-rFKd7p&joD(ba6<
zt>V9tE57`k!ZglPf7(;_D9H>owR@a+m(dLiGO&@&r*$nUO#SlAHN3OP0Nl%%g&ly?
z-iE)wP1rfs?z#vAK9Yf*t6wJ6kvb^!8(wSyQ#nu33F$^ISCenCE~L#;&4qmvGAci5)HNIlNwMy
zJcH7j^o7EX+SC<=0!>{x35m-ILE^Cjciq+5iX2dTN~u6T4lO3mxxQ
zV+>Zm^*D!W>-Z;Os)juN5mz)|>mE+dj^FmeP+Yc>;`hdBEtf>$@IRu5C*Ph4$yT81
zO-LKh@9-i#0?3UIercaQ+LFYI@+4L=vrh~)I>!89Q$%HC48fnDl95d365B?%?Tw}e
zZ+FF#$Q({gTCWZ5-sOUzq?HMO&Jlcg>G5l56tP9s^`y1sLQ+C_N&3Y4zrl=$Xot2^
zBa=ov!%1=uRH>+1<%>KOR;Q?Rv*E<4Z`>q
zF-9)N_gkf~u~`-)8b|v$hzyb%d1>FcWe&zP)CdG%D3(5df(26M=X9ks94_y*>@NL^
zAdKD!zimLiKX2anbel8|t?IQr0XW}!y^WE(4u-=N2)cR}6*_;r;VG;gUTAVOq^NC}
zpdD!3zuT3VE&(d%@>NBN$>=l4WVU=~h~4K-+}SsFNX|AsUnXpeIGvH=9DH|)RJ_zc
zf7`$^{v|sdB_}*WH9r+cMr7|+)VMNHs|DlTti=VUPmtKjd%dw#G^L<~m!(RhPYVa#
z&gW$?PA*dk%_yS6BK*%?2cj=fRle{3K@IlH)!?6@O@EZ6GF}YRF-_nSu7=P+`Rgsv
zfls$<=c$aZ*|O+O?*~E3oqAIdp*FMm#lSf2!0DC7aq`>7H{>eh4IS^fzE$TH#47o)
zaWHvr*)MCBj9OGs3q+wx@59=Z-3sCYMZaROw=zk9nZ6vLwxFNeq)Bhf8Kbl34#}GD
za*#*U(`ZyLN&A56ZjY=z6?RX1ZoD=>og)bz1m0~&_;KRg^&|v7-lad?Zk{i+zYb}*
zRK*@^j?ImNzDcu#fgR!QE*NNBK0$jTcNNH
zWKg2;C;vxjq|ppN8IFu0V!QPnJS}utb5mUdP8yn!sBgg{>emJ9U2I<`55t7l5qJa;
zymPkQ1cfA}P7dkRCGm}{X$d``R6n9c->oBC79Ok-Rk!43v#mr(X*S&l{a56J+~DGPZ-wgNMXo)4-0p|
zkkZY-kUEN`^1+jpLf&WA>Xu`VM!UEn;MIp9`L!~IQR@(9BZ+;%6h$3_FOZZ?qt^+6
zZ(_C__d={>^wKrF(mAZBR0O-
zvdTT3A;br~+=CNEw;Cd`uL58;xV)A6%2~+AO^;fzlWT*GgQ`sOfSc7v8Pg0=^P!q~
zB(ieJ!194r$OZUZ=z1z#o`AGE)S{evwu_Jw|74mQT@_2!dtlx_Df`?W;K5MG)Us7u
zf&qWloJ5aSa*KBZ0dXw|F#-C5^d`#7zVjy!NJxLaDKEEzCQ_`{@Wk-t-_Wxp_cyzE
z!8V}Pxxy~#e;>5GCoiv40LdIllq(RI#LQ-*E2sxgQ}JY~1t)>$xw(%5rM?m|_*mbA
z4`W;%D2y~1_0{!d1?`^s3>R@HpH_axO@pDLGfLm
z)MW_7$&)z{(S<8`zT3AoG+l6=26T`d7ouf^K~DK%<;vAIKDWXi`Js1%{B`y9wkGgt
zRg~^CT918R^Jc_%*=vz5waR-M1;a;c-8jHXLfS^JvE5MB_-BZ5066>p0SP#;^G5j$
z98A%^_^Tdr0P*QJABqODhVNV=urb#lrOmRkrQ`$q9fp(x(h7%^QJU}sN!oCyiwjdX
zx$k^g^9qen&4n_sY(cqm_Dl^%p<#jo&}0e_vCRz@y1_McdXtqA6#_2M!Z8&3W%r~#
zXXxh$y~lF-<<3ru1o#l7u57wJ8Q8wH?nY_G7+^M@9#TyAQ>R9GoE-T+)jpx<60}BM
zGgKZu_1LH|B!xa<-^pxKF#V;Re#C-?A+krElP1iMhEPY93D0(fq3|ouXY{2ryqw>a
zkLl?+yzDNe#M@xfKFB+vh>>Shr=eb=_qLt!^~O6kySZK)P=7VbZqL*HNxf6*=GvhKK
zM5@tK5Tq@(q%A78T;RTfB{DQ35?4$M%vu0zz$DDwfJ{T2PbVIQyle~*qK49`;Z-!S
zBuJvp3rHC0K68|UbPo@&oos+!;@*~R8<|7Jy_#uy)A(O~ULH0n^B>!mg;Ac@9VDRb
zT$mhls%rR6fgwg&*W*7tlXC0KmWW_@Ruv|-w0r0
z9vQnzk@zt&G3bc=rtter{IW~A=H8x<(s5|IdaYOQrKs*v11iUh2~U>@ypP_A^?6NMAide!zah4~Ywjb-L@z3}AjjOnAU#
zqjv^F-#5_=#{h(>)2A{z*&VHl!9pW?YtE$=fBn?$sIgcUr-{5OI3t0uuQ{STA5Q?>
ztLXW?+{JE#Z^6M(EB7voRw1EcjyVh48ip}kNr9F**(#7C*O~41A1vA-`*G@{L{($%sxc!mVSod~SQ3t`j81AjcAp1NThx
z+J16yHenhXdLwd~;`Pv8k#9S*AyDtv{LO5``HLE@*2CmnJih=9uq1Mscb5Nbln_s5
ziYbwV3rw(!N?Vo5#|kJbyr_%)$xnu+LXh{;f2Xd5;49@77eIF1-DVJ*?^y4!1Q2BH6~jJ)Uyf-?E7A
z^84yMzqvn%|Y&kY)NIqs)bgk#5h@?wqx)o|lc49P)ByDsR
z23&tY8i0_(qPIV>*wnTXT4!Wp87Y>As{Xc=`)))3ta
zhIPCv!RPRDdbiHl_Z2QT;z*_^skOazfJZzkNy)N80Gy@@sC`U0kI>*EwBgm;os_yp
zW6W{BzD@79ecuac_|?W4`+96iW;R$W_3^Z)hbk_=;<@nb`$c7Y-1I7e9n}?B-TpN3
zz12rBeEm*{J{I=E_&6$dDgH{HvP}e8XR&%tIL$XP>6{(D>Z3pF2C{7A@>)_goEVzv
zr4n%sAhrm`F)>urE{=HF_j3;XT&P4OrlIG@r;Kr%G#%OFol=$4pvxEIvdr;8tR
zA6mZgK0MOQlqZv=yN67q7zd9frgc$S0Af^M9H$sHVMo6&(OCsW?cZ>khw&5r%LgY-T@wVIyqJ<@w&E^
zmc*n4_xK1L@%3Q3Do#2&iDylnG8JU`Lu74=l3GsU0@7k_5lr2uHEZrTDNn>0*sl_8yf3`7?p$;Z!G#DN*I=qGF*B^x!rEF~T^!U6k-S
zZ;_Ew3)(5{;b7(zg6R}lg>E@DZ>17u&qSnXlL^NH6i+D=XOBrQCPLKnAY#za^8hIz
z5!GKSm1BGyE4n7J3$QGlOHEswW6cd=<^ur;;7uRr?}}!UZmh2T22Nn>z~|{LOC9>h
zxW~P%qybfNq;DxA)jxIXRxN4m3LGLzz3c!gVL7Xh%w&RJBaj%>Vtn>Eg1$Zjkclf4
z`(RHH6bB1WVKvUsiqNu4U3E}@Q(zjELnTK;4V<v%FV;mPFjhM6xwGj9;NGq)kC4zXZpK}rMmO)+~g5P6SWf^oxuSU4$+
z`JE}hlA3(kC4OmP32pb7%DtJ5Z6&(=G9vHgTcjf-|0l+=`SWx-CJUy?c$6g`nU
z_FkX_aWs`{ei}{7H*JlX&L?T9mrTRO$brB+bPPZ5&prLeAtFk7|KV=ks)ioN@;=SUIX
zx_+!xkFut0U1R^H$?I?~r)V;yCZt8pStLOX2Tmh}nkgoNh*l&C<1nN%iY(iBOP!%E
zV}S=reRomiE2??6KP($?m)dZT@N6m&*4P>+WYo?oh+fqnrS7N2mp@gDc0iim=ZACA
zcFZ)H)$I3%+SL4iQj|rQZ~E5AfnX%+LOalJ+7l$w=>h}Cx^IHRaeIPS4cJpO14UOJ
zqjb7i06sDMyP;}0+cKH>noyZ>^SZHt3a;$L>)_!5X1#5G(l8(F&uTjH9OL+((T6y*
z%Bg>-4$BCYOQ3Dy44_FsNn&D(+bPGCsbcr7p^W_P-As(X=hGSz%=+_C<92NQR4<1A6
z6jcpTsWO43-hVHC#lx~
zmCL*p#M5O-IFQQhAx1!RRmlEhDl?~QqSfEcF*}O=GhbC5--N(EX}zPBHy`b1SQL3O
zoM0luc(a0=V7c8r&V((4x?BNg_D-2)L4fDX>p`6wWdLSxSK#&7x=>Zhjxek626c-6
z=sSUO?#>gcnppQLx(UK@|`;aL1@rvJ$=pTn+id@W(<-;$>)BdZ@$|Fj-XD9L39B3Pcc
zK`p#oTi%$DGmvD>H96|8Um`J(eo3REH9ABVBQq_$_z3?dK?4!VN8+fh;)JXxi_d;Q
z&TbMOlZxb89T2-S_P}vYlxGMxeqd-M^3L-MZYQC6TxG}}La-yxRF-tqEK6j^Sj`WW
z0)DZc3Fac#wMfNnMc&tB5Q0Cp*>CFig8C9fFEyndPM=Qnr>m12G|gOK3=aSo-<*yL
z8gOd1y|#o{LYj&fLmyAIm^l+iiegB{!G;_@OP2>Ll|5ud>|9=BuqSLox>`nImW8BT
zDyjH{7YyV6f`)QO=wBqS8ZhlmmxA}(S5Df>N+XNL>fc+W`iW
zkyKH@n_A8|d!oY*PBd$SKsmyTuDU{W>Y`LA=LNV|cZ_$V^7VN50wR_v*Yof}Ka|(R
z65eFhswR%Ydn>qz!~2nUhwxj0?FXzMJv*cyauR6RIh%7_COK$X4G4G)N_skgDDkEQ
zAz$o&)w5x%7fZTky?>2QA=#BqYgZ>W8oqp4l<5ihrabzI`p>D|>~C@7m=pb+KbJdS9oL8GK^2BZI)KCJpWp)GO&6L~ez<6y@s09p<0OnC(-q15pxY
z>tg#%1hHLure=|@%)25tZn@M7T62|`DZJ>P^I0}v`%mKKVPZrkU3reSb40$f<-PKm
z)M-kVZ;D+oc#hHEQtBLHS`b|Fz`|~TC*feC!bBi#O9+OS&1+<0k9WHX_W77~o19^5
z#fFw?b3@kC8ZlqAchH5}L)D_GdNAr^c$;ELu-K@yGQq4N!86GX5N53YH)Z}W^%JP-
zQF6*8afJP3-N2YRog8*+W|0=aDBJKDynCb*f(Zr{Iq%4aEOnBoY1M`bGQc~XZkV9>
zb1c*wGCq#_MmcN8p`r1ZvJ&9P57dfSV&)$V+YoAC$k?n;3ws4$PGTJ4S9cPjPVOCh~-&oi_97Gp1a{A~cg4J=*>Kmf-D|XUHYkw#h`Ng{5(=H8#
zt#08Da~ee70tRi%-Lp8tqcdj4OWY@E(5`&;?_``}*TLB1Wqll7I^Q+ujO&i7T><{s
zhdVLA^t{1=IL6=+5y1Q+x!?5MZ^h1Us2r9o%odo&TF#5!A@v{7BG)vIPT6LpuTur=i5i!>
zWh2Wf8YAh?FEE-mREMM0K~@P^_I3a2l%fVtb!sV&$nbQ@Uts)(t0y$D4W5)n_CVpP%YUv8r>5L#Id6thYeV5IY
zw=f{CN>NuhaQ%!vNQ;A>^+QSYl67iKt))-nBY{wCRqjv>FU@#!$c{$!R>K*E49UnG
zc2IO9n>_zUBPM{-C)=l_X=Zegq9NK;cHCD~Ia?LJ3tjRZat2tvV34&o47{t9nPv|8
zqL6PZyHT_%N7VOu{j|xEk%H@d{{-i8w4!IT0geVxD#zd
zVLRt!D7G3>N9?UWsp-nI{nkSYDkK3lo%S76TVH-(Fv8T_^NbbWH^xe{y~0$hEPY^u
z?PQ#5NEburqgz1Eh2S_G(Nl2qC@{$J9$8Vl@pg5HJMG|=K@kNJK*$Q!9N$}6oJfOV
zT0lM{l3rPI8H>j|l_%*f!&>x{EU5|1BJ)m6ZOb>@gC9eZ4B|czU+G4kGQ}176C`Wo
zU`UX06E8PhPV=!{>Isry6{0TQH-yED9iVx5LVo!z5|k5Jl5@z7l<0xb
z&bb*!T#Z|*@g7~DdBZ|2ej54G8>yKy&YG+;RGAw64iucKGOFOhV>FLLoIB=0=FW6_
zNgmhN$j0wCXe(nIn^8gz(iWFh$-A^E*WfpdO<_o-ugLt7%pZ|~cO={)c0OAIf^XY>
zvh;(7psCa|?g!j9O}_wRHQP^J}dO7-mLNu|u+uty^v11Qi_z%_n^u!;#=rVdN2y
zRJ+#;}U68^D@!?h_Pgzndcm^|wUqO
zjB0X!<1gdv2(mw*nsiEVe+SMouxV0yMv1kYtq9F5Vao51nSW
z);+5^+)ON(krfeg3CA>mkUl$sR}cDXFOORatML4FH!Uol>1+QlQD;9`!j;g=(43yn
z!(#|ik6fQG7C4zMEnDE{@Gks{UH=VX6sFt){^%ZD6n_
zL@1mS60shTJ8&E)$|%(8hpP>y*(ML(iW;3`Q!dNjbGj%**AlUeHbCU4z9j43#mTv7
zO|})$KE&0q7)#dcQ-uT040hPo3Q;YZL}dZX35OctS+
z3XtxPeY3I*Vi^u%Yma%Cn_rtl9M~(?!8;itTS7na9GWVlEGB!8n{&_zsvk^CFjY<8
zrxlo-kH8%2X2TR5J@4xw-1WrP*=1|pZFPE%2N6GLVIYYgq05Zc&txzcdjQfhdWB;M3AQ-7
zpp4Gb`jw1xUzhV`XgZ(k=Js-622-D4TQ7O8GbSa*4qfErxPy7{j^bzMgh=2BSN&H@
z@U|}_Iyl;O+w?pwOLX|$*FK+=%I#?^@TERGsEYf59>@-MlHDGEb2+6XF
zTaJ7=h#Ed+C@mjqw?R{^vUN~gh
zd~fRvC^T2>e%&f1O(rh31QC?FU@$AZCct@|$E-}{tld|N=tTbi}xwp?(;TQkvp
zppi58MYASE^a_?t%3C~lptxPtjW6P3dnLTOg$B@QAC#)pNqpWtdt|=v0@}Evp}$-2
z_vX8%sIdDu9_BSMgCc?t53*-x+xaZiKQNyH=2+q`
z?aPlVj^5m(^RjbmEb4MXvAz{1PV{2GN$>qhaCY!Pu7DQ}<@HDd#s>h+i1~U(#C0Jk
zT&Fl1?ONYJvt*@yo%+^3+
z&hpr_b#*sgx)ya_Wt+nxqtgBRfRFDqI7tq7L8zzG00Y%oI-xtrx+4oW6a7;US_v+b
ze_sXIh0Ow2E5AlAhl{T20PQi4dT;m2)zYO8pD~yAip?6`eH^|-rsR*f1%_e-GI=iL
zk4$8SfY{YX64YFvkGxYjK0gwY2Kw>DDGFV|F6fwVY%mf?VOoZRh%7G`n5R3hLtZ1E
z-y5Sl!Yd*lFe#TGZnTxnl|ZUW)=%s=mbh
z-`7Lc
z!k^Uzkm0H>R}uwJN;*goTgIr^M|BATxxUM$$`@0o>su0m>AkQx*a^+9Y*{XvVrbgF80_(69e8Dh
zB`DlzP}V8}{Uf}O;CqKlXgNqd98|-MU<*Dl`8uCpn0#MPb_LDY1&%HHOAs4!rAm*V
zT#kf{<#S1#`D
zsA5UIG8BWF)Q^u>C1Y=CDYvXt!kre5%AsB8kb8*t@oHRBB|W9weJaR+A{0SDEV*Pl
z&mVT?vubi#b787SsVF@t%Q`WCyg#FVe4#^!LK`RL*87bzmR$Ecb9a8e;5Z}&L`iA%WnkP>V9JS
z-RdhCEH|vN#DRzuFf&laaGT1fO%`OIRRVb{n_R0U--RBlX|Vi2vPuGwDk{YyW^^f4
z&1;EWF8SE`!8*JZQ5&RXF!n5<_RL^ad1M}q8Q)brKrr-j9EjGKDqj%l@_
zWn$xGod%Wry?}E+J$sj|rYQ4ay?~8UCP7B3HM?%CUGDhJID7%26g}8?8{}UrV+`&cw
z-}=_KwCGy2B^`F1132)&1D!6#tjKz
zo}Rc;saBH286A1(9Q22@j2fT`X_O??Fpe_79#g?Y$;z}s^QJP>1T;-rjZ=r6je!Oc
zO*?DKcryAtoysap3IN&V3}U+nQunt;|?Hj2XTJ|fIp7a3m_0O
zEJYU2V#?)YSU9Vbwoo>72bOxJNu=%B2k5f|-CBt%34REr4kW^Tz9GgwO=myWXf~dn
zu1|j-McEII;H8av9TaX~jMkBPlb`*bR=B3reOK2xfY3$tXuMUyO&pi)P{jk*?Ca~V
zj*pLSeD`_3@fZK_4_7{~w>KVXwVKbLZcQGH8{i9J&o6}99tubw`Uep4
z{j}NoL6TH|Fcmc)k0vLdIsNq0pSk7MJKsAvIQW65pMJW5{CYxOU|_9ScZ?ERX{9?C
z0lUrtpd&?FDwWn)Gfk#pu)XM1!o(4XGK)GvJphJt?_=_Je&-!eA>i+OOc$>$Tegf$
z$I-s6R^!0tMC%1paeN49UdSJ0z5p2<1clkRR;%^*_Vz+MtIoKPN2AfiUB@{Hch?fd
zmU5yDU>9SNKaeM(ft#X8H6+clldnCb!P20pz>G*X0xZ%}5YbA{ba6D`Xq*fwrx)i+
zsp36e1h7oP#Xq?V{GJ;nLCmLp3E3Y6#WKZ0JH{|_-p(<|O2k26q!vw0O-0l7`ULLg
zKZS(H@%tf4@dJqaKH@*XcTC|TeoiQ-*3PD>Q6rTWWq;@
zoJt_wTDx{_lZtxIIp^#IKYx!{IU2w0vYUpJf$`@h^~Ql?&E^X*dw3xx3Wq_R4}}sP
z4Atk{<|g_53Ek%zPn?y||HiOT0L)G3GM2^<{oY&Q`glrj$&e$twln5dk5^2t-cql{Wih|$Jy64CY?bVO5)T5V+t
z;M!#G{)?S}(wmifeJrZT$Y${dx{l)APKA4tW*Z~Z8cg4jWxy(frB^{_VXfG2}Tj|
z^P%VmpsZ`4a06U};1HL_$%}U=97`Q>HJZ~6IExnengFG^J5q;1NaAd&K|L0BictOb
zKuID_`D_o(hiGTijv+V?79zWtpUlCzmJVJahNuKd&cS)tmOHl^x8~EJf#VJi1Lq^P
zpCPmVK=>oH#rGlPF;w$0Jaqd3D)o4^(fH|!C!8>=(XsA$K_`K;md4WlVR7NiS+>@?
zb?f*XFW0yC+;a~}(3ZY=r1Annb4is1A#D@K6Y=Dvo(SZKy5w)yjR0tr>>GIa$$_Ds
zaaw^@@?yVH0eI;SLYE4EQ#b&x4FP5&y1o$Wd*YB2tFs^>l@Ly%`S!QJoyyGLWB7dq
zzua}xO*aisOtkh-lGZ`flamKeM{o>q4zRUDu-5Q=X!ijyn0>G|IXE!Tm+E4W90h-$bpf=xDi&;^v7^=Pr@)qneR*&oNq;qn{;eX
zw67GA2fz@fPMGoeU-SSXMp)JO6(sG`O2<(*ITqYImHB70mvVHS15oAo>Y;~Du6yd4
zu?}}J>GZ7I7u-8CJonr)inDhrD3OdSJztX!;=>=enm^)9xuwjyfX4)-Ifcc4{6AtI#
zY(I*y*jcUh)UcxNird6m28ZbNdVLZG@l)_{xP>8k{C$iAzK_w#58;@8SgXcQVAS%{
zYc4?p~H#Az3e?94OIC%u+5`ZwWy`H2BO{IH)u-wQ)*Fq3VZ@kPM28{Y
zgOTw|kUkP^K}%*i#Ky%DXao7S0D>{F#p&e4cXJNaf7p{GPhh_O
z)JZ3u%r%i2FFHOq2Y(WC@FAIlV|122^2j4CI%_&V2qqY=j+=vE+AIEk(ndZd~)p)-5{2U+;d{$GgtdeEX5W*US4uf@$
zyA9+=y6FWgfJI6%MjGVzM3VV){q46OluR|hi3P}2SUqPR((lF5fM;o9Xpbp)V^@7D
zKIV)wPWXutRTh;sl4AKt$U8ZvMs&!`GdBk>6ZBlctl_f^dV>(1K^iU~I6Bnlxhm!!
z$+znq0Q(PjQyH_u|y98YM0@on+!-6Gl*^frmbI)Bj8IO-oV?lHr
zZLv!4aP*6A*y|i5Z*%T$7aS!IE+sy0!sGrZT*-=m>L(RkLGpyU_te97?0cST0X@xVt_(wPZ0+t)XM{?DYui
z*qr8?BExV6#s#ce#Vv>`TBS6neSX3ytp>ewqYng5TiYw
zCXas)z&BoA%2G|a<=v%P`VQsxXM*6SpFTy6*GoHB({|_=U|krw;DQV4*fPK=fG9Pq
zs0!}l;uv_R<1;_(xq$gU6ANX
z2cZ3eJ!yCrU4BVVc3pSo>#C1L3B?^fJaf+*f7U|um2RkyhmC0~(C4LgS4x%uvL*-)
z62{NCY^G?nVr*K9B0k0FuG)9$6ab1Z{e&Cd&)i`S{;?OJs{E$UyRA=i^`R0Ifcna|
zS=L`Kjcc0-ouJ7Q%gVR%D^GWZez+gnhK(SLp)vP9zM90443&_Pa(
zTj|f5aPd@PRrV#`U-K$HSFE{BmP}CbQLg+WCT*eZ!O+H$;KKmYiNoLyN0%n78!yoh
zQ!(ct=?mBt?(NP*MgZkIFbwMwjCQ312=4<_-~g~a6_+yt_Nrde!yNKDppF_8-3=iv
zSD(+rrp2J?3iMLLXIdQrvHW2zKA1;j@Zp2e%CvPfw;A4S5WxubOvB
z6G%0>avy*$eqoab_5mbgc&^=pp&UC@?)NgJg*EWmH)v$B>)dnCo#aLjvOuCczq%NaX;p4{YdTi5DkPY2wX2-Gb%V(6B0@M+JS&hT0Owry)$>(|Hr+mZ1o
z9(%Bx^`;?R&bD$FUIdC`aRA+R769wll@0)gEWIbjU5R$U0j!||V5d_1<$`B$Z+OOU
z{_w{>f%nXlZNK}w=lvKsKKF?rBi?Y&J+6JWZEGPdaN%7-fApAi)Ey%Lr_`$3(h~AV
zmEAr1;tNDN0$9rD;G@qH8a%rT-Hr`B(udNI*I$48K~XY&EX@Aw@iBSpxSF+(8zpi>
z6s{fn_4Ub3g1aCI=%%YH9RNB=I_km;FP!@Ok9_QDbmUWPW5(T`mE$$LO|eX|*wQ5h~qH6
z|MfJUS~oa2G{o}>@P;Fu0^U;gje!K($WH;%9XvRM!gXZHc(+{L>Hv5LcP|8`=~Ld5
zXT6KwV`tL|u=DHq;gGo@oDp~`4B^zVvGJWB{rD&E!I6@miR0e8LH9$jh!W`Qt5Al<
zQWzC{$3MHmzUWbXBwA2((PgjuQ2Hq0@nsk=EvbC~s8fO`9@J;PULBnD_19m2Xq42}
z0sltqhFUi~JUqhskB^T>W1~1a(4W^xm|hyEqr?D-$Hb{VWkgE6#KW$20B-dfy^}^i
zp#wnwQn}?~2eWg1$WtYu^v!f^Y?KE>;*g8#NMB#yX~3U0IWhjsrJua~i`WcuOD$>M
z3FUeWLS#Sq1}e|{-@liy88{CRC+SW;(n6is3Zlr`25gipj?L~knhYyr@)RH}B9dq?
zx>ygL({pH!^0QCe%S$)k{K8rzJrQRpora!13MZ(HPzSKJIoi2%XB`bdbH|BsQ5g2K
zYSagF$vY7cEN?^N(J~i{gJUstOFG)M4ghy(XYO(QB$b=?ZhMNIO*k(fM>3{T@Bq&S
zoBqY<^)n#+~m_vK^skHyiH{bk%daHFZy7i2x
z-a4vxU|@)K;US`v2myg|!!~e8-s6SEr^9NiMul=HjmKDEEyw-gsCmu3{lCJ$rpv!66gU+M!
z$kHx5lAZ7TLOvwUU$hVzCSnK)yJ_+?S-FA08w_BYa1zezI|-}EW1qb2ihHrA?3QZW
z17~pdBV2%_4BoX+7LF|B=r6*a%}bwMW5shUt@KReD6uNW0jz`U2xM8*5n#egIIqbX
zUI%v3=jN|nYq1Nj*WYr>;pueqcy!EZ%``fCcxbSn{m41}=;#>d^z1WKiI05K8albe
z$lY`QvYJtrzwph7@hC}~4=}CgAt~$4IVM3P!QhTIW!pT?;kh?oTp#`arCmwue=|R(r)gpCAZ=*(C>jn
z=B9K8_`o!N+n%+326_i?_4kkBR3JK~0hZZbnbON!fbat86b=QN^+aAPV|U{yLzvTZ
z3T2N9+xpqhz9?z5PDV$a3giD(-06o9k0xNCoYOOJ^r4R0q$ud0wjyQqY(o{DL=#iL
z3ceil$V(4D{9#i2lZD$EmpG(r9RQ7*N`-6u??3#puhptGI)HYq@Eu6y@I1Lt`?my7
z*?!HS*&H3+1!oZBVL}DdguWBNcLGKpfc6dqb;{-1@F}o&6Z#QU*8!jt;P9
zaP%HsGvM*%yjIt06Fa6FJSM%=!I4Jm2oErhVKhFi09VC7d+4G=>@=sp{(9W$ljg~2
zt<$1L`kH}(!2ue*pVQOva$^VeSNecu=dXB`!SXciqXFl#q?}+z;kg4=lEmIsM%^@
zeSn?^StSE-2FK!<{9`d%nA~{9=KHb9_Qtpt-}cV4&wd03AR`FMhm!ShxPJcm=krNK
zyC#!p0hdmNz&eF7vE0>Jzi?q(BM-YAKo$XEN#hvu$`pp@4hGsqh_LM+eDJ|44h4;2
zR2&%vr~IiKZ+vkzjZcIF`t?@SIBIBMxW?}w_&GgCZ=Bb&aoFCXySciU@^ykMNfBR6
zd%7<&)b}Zrx0}Llb^s9`-9TTcEtP+SQSuX0oX=B{s6e@k3c$M~l%M0ta5wbHK&y1c
zkOY8HK-J%6v1ZW1n!z{)rf>pSGUz`B(}iO&TA054iY;FR_@=m(+;Q%?XMfupLj(uF
zLpHfyu_$W>cp4%G%*`y`weT?t_@CVE_o~^nyz*LcyuEjH$J{&d*9&jcQLPfvF`dR>m*SI
z8i7YLO`kyhvzLQgC*hrkC-QhMLP3|v%f0nnGZ>S*I~-9@e}Dh6kb5j1H%@H4atjuk
z(wn1dax3V$>KxV#I7H^0c%knmkPjmbU$O`bd#*gzUR)Vb0uMwmhKh?lEc6&`)?J^|
zx6SEqy6NZA>DC*t1^?t$+ITfK=U^d^A9KN+e#Z{ho$Ur^?eFp|y)T1lGm-LNb=`F@twqVHz`PO9>3?NtXi&by%i;a5UAs6UARYCQqaP_#Te*ms<5k96
zgcZ}iVOqFR;N!uQU*!v8prOa?+N-M_0F*n$(36WqwEFwenP(#8$*zc&cP!vy{GGz0
zCMX@@Hr_cc2FMTBS7?iP=HA3`m&%WUK?Ps+>3J<2)obA(K6u6EtL_KQ4H%2w_KtVF
z<6AJ|+?oON=yYN)asD#Lq=)_e!7H)Myyyt@0!VfN(s2|<5K0n4@blYzvJeLuY-cfe
z5`5}YHynX|1t(#OaRyw$uVU+cg}V{`LY~a&LB{^z)GR9inHO+rU5cz0F4LV*dq-*T
z#8mh)6b4H75zCL7;0ZuzcgN8^f4kZN*ltRqbmPb`|@Vm
zfYC?424SzloW90GS=jcd2MvjNusr<9T8z&d$+=%;hOFFWxVAh{K%rA67q+-ggm(n6
z5YYNZg7(&}(D`C7JhdDg0Pi%>z(DPxUE@=a)~eORc*+YvPT4!O9Ti56r7TpoA|2rh
zE7Dk;(r5@A%j3#~ys4kuXMnu9C%tE&zke;(!PbtA?W$k7`D!RXz@Dt3o~&~X4J?WZ&vGCvr;;I6*zx|icy#c#ka!#6Zq
z^v2h1`OCDK{^HOOAKAHWxLC-mCy5-WOa1B*
z^UwTDPS!#a+FnK8`NEao@Zq!U70mxz53XPT<_R1tk(_yEHl-q~rS*a)H
zc3*NvHg|1Q<Q_PP%Qmxp13vbPuM>3C;7brt_Jizy=jX-ebFr;`y-P!tRisfkLyNO;8&?_+VOKGL0E;k~e>
z5_J6tfE|Hn;j%E5Ch7fH-Nl_Y5yhNqCs9%4;h;AMNt?9+l~V>+n5AKyj!1DD5^m#6
z^CHY&ECDtgxpfSLbv#DwfxU=9K7PazqQqoT1{=bxcF?$ZsZ0$$4D!YJWD!tL6A}jV
z=jp=_7a@3lU)i;5l(IM#;6h+-PR}~(W%E(kT*A5X&6SRM7HKujcEz*{m1%x?E@kTm
znq@Hpz_3{6s&S+7Z`fJ(7^VT#%+k5-Or5HVLK=;0sC=4cXyc04(BhP~2vb@O3wVy%
zIY%dYgnzhePidGgUbqwQ;wBr=kT;Z%be_*0gAKYOuT3AZYnM0xIK>ffl
zITQrB1rpD%X>kCU%g^Wl^lUul%XvR9l~Wd)>@=Sl!!j8bHY2klQLHi^g#js)nnt5x
z0gwaY_-oXYdvG44E^?~KZVZ=;1K>133wjR|fz8Q7J@jRI_(bKq9uz6nq!2DC994RL6}VLB7S
z%4ih^3aO{fXQsIF%{R=+G+xKTDYKXs82)fHh2x>V$TXbXvapS1u?D~neb|310BgEm
z=4+(T0`54Q)6)R*>qX_EqG(9C#x+#havY7pDVx%fkC)0-co9aty~xKqFhX$v#QA6U
zmMqd&g0c3oi_hdCN2UOlNXyQ{Pw*T-P6N`@ge)E7WM_!1F#-@@z~r-fX`6M*w1_XJ
zOZepd|3>WI|JQY+61uJeEekKl9^2~gTG-|J?|4Sd^8pB+*EE0(yi}Pq>;x0yS?HC<
z%CPZb9yaahaL10)0jzU$bmlr_$05k$4LnAG40@8{XhGftghQD|MBW7iB42L*g~gaHb1*zW>jiSdTnbt8FJO+H*m=F;hN!
zBW~F^0Ny>((J@Y!;p=%kAAtR_;M5ogyNE>faV%Im<=Z02R$9XGe@Y#I!fmJ{3^)hmmk!9bSrD)soi1C4
zxyI)!i}Xd=on#bgk#5$oE`%EV;|A<%^gA3oS7;`|OANPc902--=L5LMRPKBLDhwZ~
zEx4mq;n?SlmxTiKf0SNBrB~iS>kVJiiZAjJF9w)KgU_#2rh^X`a(}@sKPt@
z6s;VxLcKG&a3$EwGgJ|}$Uj!JqQY7V4&~{^Xkmam{>a2j<#Y_gpd(~0d~@c{id>qP
z7uGSbVMeY2)FuaeXJYlGK@r5434(RXaDL-R)c9=6D;+Pk}lr@gM5*-f%QTbRa
z7yvn(fb1O1$&+$Jp0tNAGxMPRJDi4&h9p8OMDCB3Q5H9(Ge#}15?UDI@S$lB_ETUS
z5X<}j7hk+~w{=4E+S|irxfY6k;PA;O_OFWj}Kq3>;nf&}@FZX73<$Z=rXx4Wr>6{j!9RI50m
z3)_4HuO%{Zwu+W_KA{mYuV^t3WwvmIF$5gPfAvQAYSMS`(rDMe|8MKg__A^Ue0+t)
z9XTHWw#oAWitVvmX(|~n4fT$fg9(NEqe@xC2mTJUmfqiidtumZhkaUh#`#(+iil|d
zMgUSw>rCKHK!f|?xIX{B_+pafFMY9SI+_~9trPhQiSsad(mkHi_OqeQv>cw*+2is4
zKlGs03h)0vCzY`Ue-`7*$^me`k3}rCdY%u!rsi(CHbq}(A9$IW6MHn3ZqwS`fk%
zUZhnxLplK5xN)vX+)p~aLt7rk>G(WsRBrTOCO?0?TnNVyNAd^|=^5=HuBU@|OEKXZ
zgvu#~KEK@xiZX553Pvf~%At76D%u95sGI_-<_Ho#eVOU?**_s7v1Bl!in%q;q=ZE!n`i!
z@5n|`JJ5?_^MwGMBIX=`Zu-f~wHt)eXXK8D!#N=z-`3#Qg5GSiA30#kI5=w{igE-HNB!Kz=gg2tW-Q
zTXtHjR;`ZkKE%M_;9vzmEaweYabj`b>eZ`o?shC~G22#H&<)|>bJj~LAc3zd%i4am
z*R!2wW(e~r`zcour|_~+X)6gvPiguMe86Rl(C28mGTY
z@24!T520_u@n-XtzZB`0r327?-bqxsj|WuZk_|$8vIAB5)c6_lB3xliE1Yp&MLxm`
zH2wj8EUyH2`j!vwZhYPi*7&m!!C%LVcS3yMFV=414O9z0BF+3&=r_+x9TdjBC2iOmA2#yKk9q(Cj1T+9u
z%(D1Q24d+^UV0IZi{TX93qiPxbsTGhS6+)|J-OliNc>8!89zvpy&vG$03Z{W9}JX8
zD*Lj}e$2v$_tM$Z<`t+kwTz|-CJoBh$w@bJ830Rg6Kn($Rxe57hU{B8>
z_y{;p%Gl!$fGVKMsK(4t;R<6~;ezyk+IR&n{BlS3WX>^IMj!Wg_Jt~Xtc#3z|7V1r
zM#|$yF(ujUF_5;yOc|8XWUv?ufYqd4pZ+?}i^L6q@Beo^PjRMvmNIVHEdpw1>7MyG
zjkj?Xoj1~F`%-0OI9t3}Hsz7qKgy09noI#uuRT@~=BaNMqQFD=apRB_EtJRC#imKX
z^lTR?AW_IHe_6aJs}oHTu6%^E*D(_K5@8rQElXQimX-D$E}DkB?3a0bHS|u;2O#=c
zyHr7$A>(=#;TESf49Oyo_57ItZr^_<$I>oFTvy3qsB#*Rn*q=bbNUaF)j)25Y1aT*Fu%+q;f+R%wi$rl{*EC5_u&>^m%Tf5h??#YmaX93Fc5QM2+
zk;b5!*#E)zC?1=b*dO~rme2eDrVS*zY#o3M`muP$=f4;XehmvkY>+*+AE4OqWocDd
z;%r>g8kXW(ZdIN%d+xe8h(?>i0p#ztGp38y86@Q+Gs^J!<-Cq(FLMfc$+;9>0K)tK
zY5LU*FFbi_Ilcd%YXu~htpnh)xtD6-aNrf{~xcjATc*;rjUb5!XzCUre1hgv3y
z8^@!3Q05kSH`30>8rN!5H51>%GtED8TBSDm!6}2POB$SSD;PkT#@K((>-faK81iW%2FB{SZCuZ)|&%^Z#dhbUNSsmKy!Cb^wn*
z{T4Kq08S}%<};jQxSp^L#W0nU*o(J!);+#@1QsU&eaz)f0d2FrU{7)&DCK(FT9hp2B82Tw_AByQ8sdCf
z&i}__)33?-kZ3=@4Wr1uEYi!`0dPed!#}Zle9H0xugRsa!R{^EKw~Q?Hr4<*1xR~w
zjL%Gmg6wt`j`k9eA@BZj9GC-@8>B5~dtB?G;oM~uE7C68$s%0=hh$p89Ds`@Soe|h
z60!Mf`Mm$nI@@dSzUA4H?%_B-hVg*=ZlL?_-X6|wQN&ZZamD^~U%kono^=3P=y(dJ
z4^?6w3Q7aZO*bSlr(3F&9U?VU>E)rXYdO+G=Rm_ZC#&Ol|GyQt@QL+o9cV_uU5s4T
zBLLs8vMsi^Qu$ZxYWu#Q2Fy(>_Y%dxd3J6Av)YnVfgXPN$ed9wRx!rl=J_W+x!2;c3JigfP3Tl9GLS#+_)aBxpjcO
z=1D-Y!%EUL_OyaifznnY2ZQ39_IvbdCCx#}PYfL=3_MFu0MzgoQaSQ+wBgJBjrT0m
z_y3D+x9lB&ZxDG22#WlrrM4pE%PWV1B(0`>A*k5byPeh-fhuW5jst^0Sk)dGZ0u%P
z+eaWQD||pA)0X`E|7QHMo7pZw_LVgNbeir1e>TqtSS($ER?7%L&iSiU
zs22Ll>~m2qITZ*hKjX1ryIU|lZ3rJ19Kwf2VPM=-(|)%t!WNRR8gEfnF)gw?l)>f;
zJ~vHD(?REuQ&iS0v`wJIe|!ijo*m_aOpSzp5~1OjJOr=(J6qQHiWDs
zAKD$2$QmWn#mnO78mA1>hO2{-i@ZRTMT+?VBhUZQulD`_rPIe*)fP3jG6nF>sl#S}
zKEN4JzeR26Jt<5#FJnv4sy&&HZkffb0}eo}pSSRvdtD5mj#7NsH$LESXUf%DyK@0Kw3)n^q1VDH@kmE{;fuXSmSI$tVBZ
zobfE>sc@f1nz#c%b=_y7W$PBcC8OMXy1{in_}+3pz=GcOB3;+~ZWMkAJpKX?@w
z-YbQt+V0)e2d6^2E*CC>3o1<
zXLU;ZvcuR~G6K-iOegw5bW0_dH(BmDvpUcTIp;c)o`nQuH~`Zdd;9qSrl@UExs&?U`8MPt1F-DZaRji)SuKV*o?F&4A@Bc3^8WwB
zGaQ}17#S`l87mIJ^wv^2AHWpZM^kqyP|7>AoNu>hZ_mib4c+(3ZGgq*Y4-Hu{E}Ad
z5xfQ<@BeSbXX1-Q%Qd~?08Gy>wetb8qT_>p@`>wcfX8(KzQ-KEh7DVLVD>l;3{#GT
zs}Fnq)v8r%Y!LJQ|B@*8v(m6D4!|@s=7>w~d;l#z@5mVH4jy{wq3&3)!t;5hTWF}K
z4DGkylRQmxP|hG&>@-rL&?2b1{Y!19}b_L|o41%cta_y5Po@Xfc%%KQH%9kk*A
z+8af-hc2=60dT9snE+ARhhxFyOaQ%Ow^wiXAmiid0P~S42bb3O?#sRZA4lIEAK$U^
z{{R1L@9JXXsIKtL?0A2?YoJv_lm0#OP(+}R$BLlRK2nK-(^TT6g2rC&dfi4sNJUUn
zD6ds*Cr&`^TT#IxLiDjkQ3|b)fV2o=Re9hifQVFqA>Q?Re`coNcW3U#toLVTc6L2B
zcZ9?4?7ipSIrpA(&pr2?Z#s;XQUGmC*{%2hG;?7Dq%6x6m;i_c7>aNjMr|DHev0kw
zh6^tMp-1HWgAYDfp?^dMeC>w5kD`xC0d(f_Zo>!g;sCTH)?|STIIOd7WYlDEqMq~=
zz)+M-=OGDpm-~Oz!21i}taNm-|A$gEz5Bt4?cvf&0dyE#2#+`>+GY3v4p%#=05lK5
zx5MLwx+N_N$Fj0)2ZBWbi>(3>f{*-v#{c6I`~Of6zQ-#m1<+xDB|d;@Iv0Qs@R4Z}
zA3)~Zb*K=JTM`pMB5CcYhN8&tIt5-z)KZfZ`pZ?oERh0s0
z=Lo*jOq4i*Sms_&CIKX=8*zVkybExjFopsOBDWN}VNVG3A>`MCH$zb}9fl;#Eu8-g
zi|7q+|8LU26Nb=UuA>w{m(guac+EK{CcHRi>ryx#w_OXsY6=SgXtzTVPTetD7C<~6
zyL&I7$l?EURQ`W=7AZuaZoH8hb(rw(`vMG|c+%YW#`N-X?+q6ha9EwRk#R`DqGS!L
zjfm(bYPA|BctSV;rC%o^+IxnKvH-daB_@oR;FXNIinDcz6BzexfCjAop`ro-QNslT
z>B6^|+_s48NPI`)JhD&HuM?C0#ikod0dyG(FZW%4=zhF6kT`+v3ngNlMB-ZfaC{3)
zfK?Ip(&ga>y6p%BFc67hR|cM=z;(p99Y21&N`H~2miH4S`zvHTGAUYk^e9IKGI(7
zlE#z*=rK?dA3(RRecQI|OWX!v@<%FIJ(>^v6T60xKyeHh^*irYR^0C3;h_Ma1F^sc
z?%-%D2>36{YQ9f=M>eU#f4ehYDS+<0;V*pig%854TXm0kI(QUE=M4L$&SWo1RYP8O>k
zUMD3M|JeEgT;oKSs6bR+J)SPuPvC`1%+NhFp!fv>ddeAdTI^4*=Ksf6X$KpGA(U1M
zz$a7TQ2H0om2nAx37^$QT1;#KG+iVEvEhhQf~f%L`U4mU1T2Q@D*9#9PqP91KaAI_
z`w!+sMv@E?lwQqL4gaYXEXuf~;=(0TNdT2oLbOJH~i2j;~9=d-W+ZICw0QDYbr2u;KNB%0}0dNHH
zvM_xk+*`{AqWHfnsCouUDouK;tNS8ZrR7$_qnm+qf#f5hO+ryzt^Gf@C$8W8XY+wmXujgDe$BY8f>Z#+E`XqlSb)oyFGpel$gKo24JQl=S&U9WsGP5k
zhk;W{_x0cCV=T96Vlu_(AEf|%4hB|JvGFw=?JUwvA!vyVZq(zX1Skwt05}X6(SSZ5
z&37@-pJ0*=t~faXc>LMGbEkMb3H<+;*g=*5PcHPolmh6_BT<6pB#)Jf#i{cEntXtA
z0LZB%RvdT*<8je0cP#)nA0Vn;ef+VDF60=RgaeC;^uTn;f1FNW+oNlno7MXZg=7EW
zIN>k$*bM_ed;|oP0tmoFOe%+yYql``$7a28d!yO-G?UK2DW*{g?C7fKS|V}_5Um9u
zT?x9ayJ$c={H)%Pk>54)ztL!XVl|rIFBS8D@`OKb4%z^-Y_vs{0tm)$Dgfv7>CZ-8qP~)AJJ(IY!8F#o6JB$3!JW>)N*10(`oEf5b{jVhsdi+7L>U&&_N$mjeG^
zZ<_i|v$Oes!ts~n_za=FJxdZ{Q|b!H0S8{@1}0kacs~CTtnNcg%TGO%%jJ%i%VkbX
z9zBiR0fl}D?>xhExL4;h9avk7L>OA=LWWcTBrALYY-ZG#h37M}+3Z-QT>dkxvHREo
zjt91BvDuD|nkix1Qwks?=P=>wv;;628zAwsrIV*F;KiAb13yrMlaTjB7GX&8p{ofk
z5V;f3MHhmHL7)MJ?%d!N8#so9>&4X8*DDW~iqlV$B*y_tSqXm#{;DlN2&Ty@o+V&%
z^)1d!Ke1V>-9zDrA^>ub@C(SIBl$o2fp!Rmr3nkbON1GCscxYQ0TI~U{trwtFh9so
z^0nEt>a}|P?ox5;Nho1D`%I2QV1lZG;DAy915N}?z$Rnmgu)$Fs#Mv;hQ@ieNTy$e_{TiLUAn%Y%UxR5Ht*-
z;B^>jX=XARW4(N3p;XAvkp%fWKd+p9!P(il#ziU$8HgN~nS5cn?WF>XeFbO>G@!7w{MI9%$S)myt5&L0QuxVNCH#K5
z=&zho0RH$Cs1%)2Oh$pJYR=9~p8~Y#9SBzW469iZwzGI&bs&NP3JV1Q&<=?T6rgIy
zq8BcFSD5=WyWwBVc=U(11zWxn=Te}^Rmrs$GllW84zK%mQ(9IEre&^ZW5u@A1__mx8kUU?4AK^Y?uX}&u16WkSAA4fbwB(Pvofn0apb)+XPfFL~Z
zQHhm(_XW&JOo{O|IiJc6uf#gKm>poHHK7eka$7C;E51+5~TgZw}0{&exg@@guT
znQt`e3CpsPQK<3AFoFwVz}P@TUecSJ5*_&PKy)RlZljUNYI-zE2#^X1dmUXm*I12EH
zRw)B~RAgU^`@{R76u|I!yNxm?b(8yatf~xMU0GN-b+Nu#gI^%~opQMni6!6(Xv#6|
z3_Eb~;$XOd2m%tq4l89G9t)`ewmnvHW=?|cjt5o{=
zXQ2Fpg{9?_@POy}RvTP{Boh%7L^mxYAll
z_skUvXBphX90NYTUm~hp^c+wMAbOq#rU(VVq!*3*U?_o?mX?-ohVcIth&MOkXp90v
zfRVn|8sw6Dbcev5gzq9vO&d%D?7xaCbPdSzq6Of75?_1%BLGqm_mC>Dm*2E4{l{~2
zg-c#=kMi>OWZh_f>^rGO(+$-MIUxN4#bWV&1jF1~DX%||$U$xgC~$o}
zSNa7!)A(Lw6#h1yPCrtw*WuT*>+tXOJc`5dn!w4|br%;fWH+`|gKnAd#Ggh+S4}`G
z#I~Kf*$VEd%mcRTF%pEOqk?<3adu~KC<|a``QNd13P1PA6f`)?9t8~fQGB*i=@f;(
zr*jbU2NNCtcoqQn*=VGzz;^7w)tkD9D#XPf7KMy7yjr0B6Z@A&KXF>n1ai+2^#3tF
z2cHKDK$-pR4BO%FN&)Qf0CZ~}+W~RjMlU&5&}Wg0e+WTC9|B(>1v%skNqTtWkyqov
zf_07;R1h5xO@Z68x;qX4`5WB9-tli1&D+*Gjn7OWDYMb
zojN_99X}?6h9F=7A-yW(-be2gAduI-Q!fQ!m;u`Yi_k7l9a_OXNaH;PYb8%2P#kK%
z--(+syQeIGm<>p`?rcdW(q@a(Q^c+wMVDvl>-6Djli9dr5HAD_BG%Wi^cs$2!23`($;rt{wcq!}+4vMv
z7C?N)!go)&$a70TC)=-PX5JYCRz8IP1utkwDjxsprN~$PA>m0nN0nf=k);C
zFp*eDP>|1i66yKlVeu;jo94ekp9A*$!04|$j{W|l{E^!O3V%;B>c`k}Kv@8>9U6Zf
zlR-l)U7X23jEKRbO{=LPAK+_9yp!02{)n}ga^;`z1sFpn;65N5YY6U{WN;FSir^l$
z-(F39-#r{q7QpTqtFAp|kPV%0KpG-+_OlBM%kSz5{fv>)u1BRyT`Tta;edxG)NsuG
zhGSo9>dkw9Stz{5RUZ+8mJ06a!$9@3;=m|5;5r9^-;WD0OUDeyC7j(qsLF
zr=R}T^78UG`Pn@Y=$;F}GU`$sP#oAn4!{~{S$0y0!**wD{jGGv(o^*m2NVZJzyWRv
wwhHecO_CP>2BD$qDh? 0 {
+ fullPath := path[0]
+ if strings.Contains(fullPath, "#") {
+ parts := strings.SplitN(fullPath, "#", 2)
+ pagePath := parts[0]
+ fragment := parts[1]
+ url += pagePath + "/#" + fragment
+ } else {
+ url += fullPath
+ }
+ }
+
+ // Use the injected displayService, which satisfies the local displayer interface.
+ s.displayService.OpenWindow("docs", application.WebviewWindowOptions{
+ Title: "Lethean Documentation",
+ Height: 600,
+ Width: 1000,
+ URL: url,
+ AlwaysOnTop: true,
+ Frameless: false,
+ })
+}
+
+// ServeHTTP serves the embedded documentation assets.
+func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ http.FileServerFS(docsStatic).ServeHTTP(w, r)
+}
diff --git a/docs/static/assets/style.css b/docs/static/assets/style.css
new file mode 100644
index 0000000..e69de29
diff --git a/docs/static/index.html b/docs/static/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/filesystem/client.go b/filesystem/client.go
new file mode 100644
index 0000000..6b22d19
--- /dev/null
+++ b/filesystem/client.go
@@ -0,0 +1,45 @@
+package filesystem
+
+import (
+ "core/filesystem/sftp"
+ "core/filesystem/webdav"
+)
+
+// NewSFTPMedium creates and returns a new SFTP medium.
+func NewSFTPMedium(cfg sftp.ConnectionConfig) (Medium, error) {
+ return sftp.New(cfg)
+}
+
+// NewWebDAVMedium creates and returns a new WebDAV medium.
+func NewWebDAVMedium(cfg webdav.ConnectionConfig) (Medium, error) {
+ return webdav.New(cfg)
+}
+
+// Read retrieves the content of a file from the given medium.
+func Read(m Medium, path string) (string, error) {
+ return m.Read(path)
+}
+
+// Write saves content to a file on the given medium.
+func Write(m Medium, path, content string) error {
+ return m.Write(path, content)
+}
+
+// EnsureDir ensures a directory exists on the given medium.
+func EnsureDir(m Medium, path string) error {
+ return m.EnsureDir(path)
+}
+
+// IsFile checks if a path is a file on the given medium.
+func IsFile(m Medium, path string) bool {
+ return m.IsFile(path)
+}
+
+// Copy copies a file from a source medium to a destination medium.
+func Copy(sourceMedium Medium, sourcePath string, destMedium Medium, destPath string) error {
+ content, err := sourceMedium.Read(sourcePath)
+ if err != nil {
+ return err
+ }
+ return destMedium.Write(destPath, content)
+}
diff --git a/filesystem/client_test.go b/filesystem/client_test.go
new file mode 100644
index 0000000..5bf1a5c
--- /dev/null
+++ b/filesystem/client_test.go
@@ -0,0 +1,31 @@
+package filesystem
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRead(t *testing.T) {
+ m := NewMockMedium()
+ m.Files["test.txt"] = "hello"
+ content, err := Read(m, "test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", content)
+}
+
+func TestWrite(t *testing.T) {
+ m := NewMockMedium()
+ err := Write(m, "test.txt", "hello")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", m.Files["test.txt"])
+}
+
+func TestCopy(t *testing.T) {
+ source := NewMockMedium()
+ dest := NewMockMedium()
+ source.Files["test.txt"] = "hello"
+ err := Copy(source, "test.txt", dest, "test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", dest.Files["test.txt"])
+}
diff --git a/filesystem/filesystem.go b/filesystem/filesystem.go
new file mode 100644
index 0000000..a9beecd
--- /dev/null
+++ b/filesystem/filesystem.go
@@ -0,0 +1,29 @@
+package filesystem
+
+import ()
+
+// Medium defines the standard interface for a storage backend.
+// This allows for different implementations (e.g., local disk, S3, SFTP)
+// to be used interchangeably.
+type Medium interface {
+ // Read retrieves the content of a file as a string.
+ Read(path string) (string, error)
+
+ // Write saves the given content to a file, overwriting it if it exists.
+ Write(path, content string) error
+
+ // EnsureDir makes sure a directory exists, creating it if necessary.
+ EnsureDir(path string) error
+
+ // IsFile checks if a path exists and is a regular file.
+ IsFile(path string) bool
+
+ // FileGet is a convenience function that reads a file from the medium.
+ FileGet(path string) (string, error)
+
+ // FileSet is a convenience function that writes a file to the medium.
+ FileSet(path, content string) error
+}
+
+// Pre-initialized, sandboxed medium for the local filesystem.
+var Local Medium
diff --git a/filesystem/filesystem_test.go b/filesystem/filesystem_test.go
new file mode 100644
index 0000000..8d6e6ae
--- /dev/null
+++ b/filesystem/filesystem_test.go
@@ -0,0 +1,3 @@
+package filesystem
+
+import ()
diff --git a/filesystem/local/client.go b/filesystem/local/client.go
new file mode 100644
index 0000000..0efe171
--- /dev/null
+++ b/filesystem/local/client.go
@@ -0,0 +1,83 @@
+package local
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// New creates a new instance of the local storage medium.
+// It requires a root path to sandbox all file operations.
+func New(rootPath string) (*Medium, error) {
+ if err := os.MkdirAll(rootPath, os.ModePerm); err != nil {
+ return nil, fmt.Errorf("could not create root directory at %s: %w", rootPath, err)
+ }
+ return &Medium{root: rootPath}, nil
+}
+
+// path returns a full, safe path within the medium's root.
+func (m *Medium) path(subpath string) (string, error) {
+ if strings.Contains(subpath, "..") {
+ return "", fmt.Errorf("path traversal attempt detected")
+ }
+ return filepath.Join(m.root, subpath), nil
+}
+
+// Read retrieves the content of a file from the local disk.
+func (m *Medium) Read(path string) (string, error) {
+ safePath, err := m.path(path)
+ if err != nil {
+ return "", err
+ }
+ data, err := os.ReadFile(safePath)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+// Write saves the given content to a file on the local disk.
+func (m *Medium) Write(path, content string) error {
+ safePath, err := m.path(path)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(safePath)
+ if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+ return err
+ }
+ return os.WriteFile(safePath, []byte(content), 0644)
+}
+
+// EnsureDir makes sure a directory exists on the local disk.
+func (m *Medium) EnsureDir(path string) error {
+ safePath, err := m.path(path)
+ if err != nil {
+ return err
+ }
+ return os.MkdirAll(safePath, os.ModePerm)
+}
+
+// IsFile checks if a path exists and is a regular file on the local disk.
+func (m *Medium) IsFile(path string) bool {
+ safePath, err := m.path(path)
+ if err != nil {
+ return false
+ }
+ info, err := os.Stat(safePath)
+ if os.IsNotExist(err) {
+ return false
+ }
+ return !info.IsDir()
+}
+
+// FileGet is a convenience function that reads a file from the medium.
+func (m *Medium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+// FileSet is a convenience function that writes a file to the medium.
+func (m *Medium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/filesystem/local/client_test.go b/filesystem/local/client_test.go
new file mode 100644
index 0000000..ff3dce7
--- /dev/null
+++ b/filesystem/local/client_test.go
@@ -0,0 +1,154 @@
+package local
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNew(t *testing.T) {
+ // Create a temporary directory for testing
+ testRoot, err := os.MkdirTemp("", "local_test_root")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot) // Clean up after the test
+
+ // Test successful creation
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+ assert.NotNil(t, medium)
+ assert.Equal(t, testRoot, medium.root)
+
+ // Verify the root directory exists
+ info, err := os.Stat(testRoot)
+ assert.NoError(t, err)
+ assert.True(t, info.IsDir())
+
+ // Test creating a new instance with an existing directory (should not error)
+ medium2, err := New(testRoot)
+ assert.NoError(t, err)
+ assert.NotNil(t, medium2)
+}
+
+func TestPath(t *testing.T) {
+ testRoot := "/tmp/test_root"
+ medium := &Medium{root: testRoot}
+
+ // Valid path
+ validPath, err := medium.path("file.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(testRoot, "file.txt"), validPath)
+
+ // Subdirectory path
+ subDirPath, err := medium.path("dir/sub/file.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(testRoot, "dir", "sub", "file.txt"), subDirPath)
+
+ // Path traversal attempt
+ _, err = medium.path("../secret.txt")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+
+ _, err = medium.path("dir/../../secret.txt")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+}
+
+func TestReadWrite(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_read_write_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ fileName := "testfile.txt"
+ filePath := filepath.Join("subdir", fileName)
+ content := "Hello, Gopher!\nThis is a test file."
+
+ // Test Write
+ err = medium.Write(filePath, content)
+ assert.NoError(t, err)
+
+ // Verify file content by reading directly from OS
+ readContent, err := os.ReadFile(filepath.Join(testRoot, filePath))
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(readContent))
+
+ // Test Read
+ readByMedium, err := medium.Read(filePath)
+ assert.NoError(t, err)
+ assert.Equal(t, content, readByMedium)
+
+ // Test Read non-existent file
+ _, err = medium.Read("nonexistent.txt")
+ assert.Error(t, err)
+ assert.True(t, os.IsNotExist(err))
+
+ // Test Write to a path with traversal attempt
+ writeErr := medium.Write("../badfile.txt", "malicious content")
+ assert.Error(t, writeErr)
+ assert.Contains(t, writeErr.Error(), "path traversal attempt detected")
+}
+
+func TestEnsureDir(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_ensure_dir_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ dirName := "newdir/subdir"
+ dirPath := filepath.Join(testRoot, dirName)
+
+ // Test creating a new directory
+ err = medium.EnsureDir(dirName)
+ assert.NoError(t, err)
+ info, err := os.Stat(dirPath)
+ assert.NoError(t, err)
+ assert.True(t, info.IsDir())
+
+ // Test ensuring an existing directory (should not error)
+ err = medium.EnsureDir(dirName)
+ assert.NoError(t, err)
+
+ // Test ensuring a directory with path traversal attempt
+ err = medium.EnsureDir("../bad_dir")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+}
+
+func TestIsFile(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_is_file_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ // Create a test file
+ fileName := "existing_file.txt"
+ filePath := filepath.Join(testRoot, fileName)
+ err = os.WriteFile(filePath, []byte("content"), 0644)
+ assert.NoError(t, err)
+
+ // Create a test directory
+ dirName := "existing_dir"
+ dirPath := filepath.Join(testRoot, dirName)
+ err = os.Mkdir(dirPath, 0755)
+ assert.NoError(t, err)
+
+ // Test with an existing file
+ assert.True(t, medium.IsFile(fileName))
+
+ // Test with a non-existent file
+ assert.False(t, medium.IsFile("nonexistent_file.txt"))
+
+ // Test with a directory
+ assert.False(t, medium.IsFile(dirName))
+
+ // Test with path traversal attempt
+ assert.False(t, medium.IsFile("../bad_file.txt"))
+}
diff --git a/filesystem/local/local.go b/filesystem/local/local.go
new file mode 100644
index 0000000..61f2447
--- /dev/null
+++ b/filesystem/local/local.go
@@ -0,0 +1,6 @@
+package local
+
+// Medium implements the filesystem.Medium interface for the local disk.
+type Medium struct {
+ root string
+}
diff --git a/filesystem/mock.go b/filesystem/mock.go
new file mode 100644
index 0000000..e97327b
--- /dev/null
+++ b/filesystem/mock.go
@@ -0,0 +1,47 @@
+package filesystem
+
+import "github.com/stretchr/testify/assert"
+
+// MockMedium implements the Medium interface for testing purposes.
+type MockMedium struct {
+ Files map[string]string
+ Dirs map[string]bool
+}
+
+func NewMockMedium() *MockMedium {
+ return &MockMedium{
+ Files: make(map[string]string),
+ Dirs: make(map[string]bool),
+ }
+}
+
+func (m *MockMedium) Read(path string) (string, error) {
+ content, ok := m.Files[path]
+ if !ok {
+ return "", assert.AnError // Simulate file not found error
+ }
+ return content, nil
+}
+
+func (m *MockMedium) Write(path, content string) error {
+ m.Files[path] = content
+ return nil
+}
+
+func (m *MockMedium) EnsureDir(path string) error {
+ m.Dirs[path] = true
+ return nil
+}
+
+func (m *MockMedium) IsFile(path string) bool {
+ _, ok := m.Files[path]
+ return ok
+}
+
+func (m *MockMedium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+func (m *MockMedium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/filesystem/sftp/client.go b/filesystem/sftp/client.go
new file mode 100644
index 0000000..a745a90
--- /dev/null
+++ b/filesystem/sftp/client.go
@@ -0,0 +1,125 @@
+package sftp
+
+import (
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+
+ "github.com/pkg/sftp"
+ "github.com/skeema/knownhosts"
+ "golang.org/x/crypto/ssh"
+)
+
+// New creates a new, connected instance of the SFTP storage medium.
+func New(cfg ConnectionConfig) (*Medium, error) {
+ var authMethods []ssh.AuthMethod
+
+ if cfg.KeyFile != "" {
+ key, err := os.ReadFile(cfg.KeyFile)
+ if err != nil {
+ return nil, fmt.Errorf("unable to read private key: %w", err)
+ }
+ signer, err := ssh.ParsePrivateKey(key)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse private key: %w", err)
+ }
+ authMethods = append(authMethods, ssh.PublicKeys(signer))
+ } else if cfg.Password != "" {
+ authMethods = append(authMethods, ssh.Password(cfg.Password))
+ } else {
+ return nil, fmt.Errorf("no authentication method provided (password or keyfile)")
+ }
+
+ kh, err := knownhosts.New(filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read known_hosts: %w", err)
+ }
+
+ sshConfig := &ssh.ClientConfig{
+ User: cfg.User,
+ Auth: authMethods,
+ HostKeyCallback: kh.HostKeyCallback(),
+ }
+
+ addr := net.JoinHostPort(cfg.Host, cfg.Port)
+ conn, err := ssh.Dial("tcp", addr, sshConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to dial ssh: %w", err)
+ }
+
+ sftpClient, err := sftp.NewClient(conn)
+ if err != nil {
+ // Ensure the underlying ssh connection is closed on failure
+ conn.Close()
+ return nil, fmt.Errorf("failed to create sftp client: %w", err)
+ }
+
+ return &Medium{client: sftpClient}, nil
+}
+
+// Read retrieves the content of a file from the SFTP server.
+func (m *Medium) Read(path string) (string, error) {
+ file, err := m.client.Open(path)
+ if err != nil {
+ return "", fmt.Errorf("sftp: failed to open file %s: %w", path, err)
+ }
+ defer file.Close()
+
+ data, err := io.ReadAll(file)
+ if err != nil {
+ return "", fmt.Errorf("sftp: failed to read file %s: %w", path, err)
+ }
+
+ return string(data), nil
+}
+
+// Write saves the given content to a file on the SFTP server.
+func (m *Medium) Write(path, content string) error {
+ // Ensure the remote directory exists first.
+ dir := filepath.Dir(path)
+ if err := m.EnsureDir(dir); err != nil {
+ return err
+ }
+
+ file, err := m.client.Create(path)
+ if err != nil {
+ return fmt.Errorf("sftp: failed to create file %s: %w", path, err)
+ }
+ defer file.Close()
+
+ if _, err := file.Write([]byte(content)); err != nil {
+ return fmt.Errorf("sftp: failed to write to file %s: %w", path, err)
+ }
+
+ return nil
+}
+
+// EnsureDir makes sure a directory exists on the SFTP server.
+func (m *Medium) EnsureDir(path string) error {
+ // MkdirAll is idempotent, so it won't error if the path already exists.
+ return m.client.MkdirAll(path)
+}
+
+// IsFile checks if a path exists and is a regular file on the SFTP server.
+func (m *Medium) IsFile(path string) bool {
+ info, err := m.client.Stat(path)
+ if err != nil {
+ // If the error is "not found", it's definitely not a file.
+ // For any other error, we also conservatively say it's not a file.
+ return false
+ }
+ // Return true only if it's not a directory.
+ return !info.IsDir()
+}
+
+// FileGet is a convenience function that reads a file from the medium.
+func (m *Medium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+// FileSet is a convenience function that writes a file to the medium.
+func (m *Medium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/filesystem/sftp/sftp.go b/filesystem/sftp/sftp.go
new file mode 100644
index 0000000..cf9e2e1
--- /dev/null
+++ b/filesystem/sftp/sftp.go
@@ -0,0 +1,19 @@
+package sftp
+
+import (
+ "github.com/pkg/sftp"
+)
+
+// Medium implements the filesystem.Medium interface for the SFTP protocol.
+type Medium struct {
+ client *sftp.Client
+}
+
+// ConnectionConfig holds the necessary details to connect to an SFTP server.
+type ConnectionConfig struct {
+ Host string
+ Port string
+ User string
+ Password string // For password-based auth
+ KeyFile string // Path to a private key for key-based auth
+}
diff --git a/filesystem/webdav/client.go b/filesystem/webdav/client.go
new file mode 100644
index 0000000..7ed4f74
--- /dev/null
+++ b/filesystem/webdav/client.go
@@ -0,0 +1,16 @@
+package webdav
+
+import "net/http"
+
+// Medium implements the filesystem.Medium interface for the WebDAV protocol.
+type Medium struct {
+ client *http.Client
+ baseURL string // e.g., https://dav.example.com/remote.php/dav/files/username/
+}
+
+// ConnectionConfig holds the necessary details to connect to a WebDAV server.
+type ConnectionConfig struct {
+ URL string // The full base URL of the WebDAV share.
+ User string
+ Password string
+}
diff --git a/filesystem/webdav/webdav.go b/filesystem/webdav/webdav.go
new file mode 100644
index 0000000..db0ac66
--- /dev/null
+++ b/filesystem/webdav/webdav.go
@@ -0,0 +1,183 @@
+package webdav
+
+import (
+ "bytes"
+ _ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "path"
+ "strings"
+)
+
+// New creates a new, connected instance of the WebDAV storage medium.
+func New(cfg ConnectionConfig) (*Medium, error) {
+ transport := &authTransport{
+ Username: cfg.User,
+ Password: cfg.Password,
+ Wrapped: http.DefaultTransport,
+ }
+
+ httpClient := &http.Client{Transport: transport}
+
+ // Ping the server to ensure the connection and credentials are valid.
+ // We do a PROPFIND on the root, which is a standard WebDAV operation.
+ req, err := http.NewRequest("PROPFIND", cfg.URL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("webdav: failed to create ping request: %w", err)
+ }
+ req.Header.Set("Depth", "0")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("webdav: connection test failed: %w", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("webdav: connection test failed with status %s", resp.Status)
+ }
+
+ return &Medium{
+ client: httpClient,
+ baseURL: cfg.URL,
+ }, nil
+}
+
+// Read retrieves the content of a file from the WebDAV server.
+func (m *Medium) Read(p string) (string, error) {
+ url := m.resolveURL(p)
+ resp, err := m.client.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("webdav: GET request for %s failed: %w", p, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("webdav: failed to read %s, status: %s", p, resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("webdav: failed to read response body for %s: %w", p, err)
+ }
+
+ return string(data), nil
+}
+
+// Write saves the given content to a file on the WebDAV server.
+func (m *Medium) Write(p, content string) error {
+ // Ensure the parent directory exists first.
+ dir := path.Dir(p)
+ if dir != "." && dir != "/" {
+ if err := m.EnsureDir(dir); err != nil {
+ return err // This will be a detailed error from EnsureDir
+ }
+ }
+
+ url := m.resolveURL(p)
+ req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(content)))
+ if err != nil {
+ return fmt.Errorf("webdav: failed to create PUT request: %w", err)
+ }
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("webdav: PUT request for %s failed: %w", p, err)
+ }
+ defer resp.Body.Close()
+
+ // StatusCreated (201) or StatusNoContent (204) are success codes for PUT.
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("webdav: failed to write %s, status: %s", p, resp.Status)
+ }
+
+ return nil
+}
+
+// EnsureDir makes sure a directory exists on the WebDAV server, creating parent dirs as needed.
+func (m *Medium) EnsureDir(p string) error {
+ // To mimic MkdirAll, we create each part of the path sequentially.
+ parts := strings.Split(p, "/")
+ currentPath := ""
+ for _, part := range parts {
+ if part == "" {
+ continue
+ }
+ currentPath = path.Join(currentPath, part)
+ url := m.resolveURL(currentPath) + "/" // MKCOL needs a trailing slash
+
+ req, err := http.NewRequest("MKCOL", url, nil)
+ if err != nil {
+ return fmt.Errorf("webdav: failed to create MKCOL request for %s: %w", currentPath, err)
+ }
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("webdav: MKCOL request for %s failed: %w", currentPath, err)
+ }
+ resp.Body.Close()
+
+ // 405 Method Not Allowed means it already exists, which is fine for us.
+ // 201 Created is a success.
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusMethodNotAllowed {
+ return fmt.Errorf("webdav: failed to create directory %s, status: %s", currentPath, resp.Status)
+ }
+ }
+ return nil
+}
+
+// IsFile checks if a path exists and is a regular file on the WebDAV server.
+func (m *Medium) IsFile(p string) bool {
+ url := m.resolveURL(p)
+ req, err := http.NewRequest("PROPFIND", url, nil)
+ if err != nil {
+ return false
+ }
+ req.Header.Set("Depth", "0")
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+
+ // If we get anything other than a Multi-Status, it's probably not a file.
+ if resp.StatusCode != http.StatusMultiStatus {
+ return false
+ }
+
+ // A simple check: if the response body contains the string for a collection, it's a directory.
+ // A more robust implementation would parse the XML response.
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false
+ }
+
+ return !strings.Contains(string(body), "")
+}
+
+// resolveURL joins the base URL with a path segment, ensuring correct slashes.
+func (m *Medium) resolveURL(p string) string {
+ return strings.TrimSuffix(m.baseURL, "/") + "/" + strings.TrimPrefix(p, "/")
+}
+
+// authTransport is a custom http.RoundTripper to inject Basic Auth.
+type authTransport struct {
+ Username string
+ Password string
+ Wrapped http.RoundTripper
+}
+
+func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req.SetBasicAuth(t.Username, t.Password)
+ return t.Wrapped.RoundTrip(req)
+}
+
+// FileGet is a convenience function that reads a file from the medium.
+func (m *Medium) FileGet(path string) (string, error) {
+ return m.Read(path)
+}
+
+// FileSet is a convenience function that writes a file to the medium.
+func (m *Medium) FileSet(path, content string) error {
+ return m.Write(path, content)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..faf2aa8
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,60 @@
+module core
+
+go 1.24.0
+
+toolchain go1.24.3
+
+require (
+ github.com/ProtonMail/go-crypto v1.3.0
+ github.com/adrg/xdg v0.5.3
+ github.com/nicksnyder/go-i18n/v2 v2.6.0
+ github.com/pkg/sftp v1.13.10
+ github.com/skeema/knownhosts v1.3.1
+ github.com/stretchr/testify v1.11.1
+ github.com/wailsapp/wails/v3 v3.0.0-alpha.36
+ golang.org/x/crypto v0.43.0
+ golang.org/x/text v0.30.0
+)
+
+require (
+ dario.cat/mergo v1.0.1 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/cloudflare/circl v1.6.0 // indirect
+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/ebitengine/purego v0.8.2 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
+ github.com/go-git/go-git/v5 v5.13.2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/kr/fs v0.1.0 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/lmittmann/tint v1.0.7 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pjbgf/sha1cd v0.3.2 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/rogpeppe/go-internal v1.13.1 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ golang.org/x/net v0.46.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..4f47069
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,158 @@
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
+github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
+github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM=
+github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
+github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
+github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/letheanVPN/desktop v0.0.0-20251022090216-fa2e81586780 h1:lhVZUj74gA0SoVQ2H6u+ZSHzHjwBD5ALgxILcPeGHE8=
+github.com/letheanVPN/desktop v0.0.0-20251022090216-fa2e81586780/go.mod h1:rVikQURn9JFi8sxMzBSyjjtp7PJYS71es30NnIY8Hdw=
+github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y=
+github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
+github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
+github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
+github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v3 v3.0.0-alpha.36 h1:GQ8vSrFgafITwMd/p4k+WBjG9K/anma9Pk2eJ/5CLsI=
+github.com/wailsapp/wails/v3 v3.0.0-alpha.36/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
+golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/workspace/local.go b/workspace/local.go
new file mode 100644
index 0000000..4eab633
--- /dev/null
+++ b/workspace/local.go
@@ -0,0 +1,41 @@
+package workspace
+
+import "core/filesystem"
+
+// localMedium implements the Medium interface for the local disk.
+type localMedium struct{}
+
+// NewLocalMedium creates a new instance of the local storage medium.
+func NewLocalMedium() filesystem.Medium {
+ return &localMedium{}
+}
+
+// FileGet reads a file from the local disk.
+func (m *localMedium) FileGet(path string) (string, error) {
+ return filesystem.Read(filesystem.Local, path)
+}
+
+// FileSet writes a file to the local disk.
+func (m *localMedium) FileSet(path, content string) error {
+ return filesystem.Write(filesystem.Local, path, content)
+}
+
+// Read reads a file from the local disk.
+func (m *localMedium) Read(path string) (string, error) {
+ return filesystem.Read(filesystem.Local, path)
+}
+
+// Write writes a file to the local disk.
+func (m *localMedium) Write(path, content string) error {
+ return filesystem.Write(filesystem.Local, path, content)
+}
+
+// EnsureDir creates a directory on the local disk.
+func (m *localMedium) EnsureDir(path string) error {
+ return filesystem.EnsureDir(filesystem.Local, path)
+}
+
+// IsFile checks if a path exists and is a file on the local disk.
+func (m *localMedium) IsFile(path string) bool {
+ return filesystem.IsFile(filesystem.Local, path)
+}
diff --git a/workspace/service.go b/workspace/service.go
new file mode 100644
index 0000000..f211f16
--- /dev/null
+++ b/workspace/service.go
@@ -0,0 +1,124 @@
+package workspace
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+
+ "core/config"
+ "core/crypt/lib/lthn"
+ "core/crypt/lib/openpgp"
+ "core/filesystem"
+)
+
+// NewService creates a new WorkspaceService.
+func NewService(cfg *config.Config, medium filesystem.Medium) *Service {
+ return &Service{
+ config: cfg,
+ workspaceList: make(map[string]string),
+ medium: medium,
+ }
+}
+
+// ServiceStartup Startup initializes the service, loading the workspace list.
+func (s *Service) ServiceStartup() error {
+ listPath := filepath.Join(s.config.WorkspacesDir, listFile)
+
+ if s.medium.IsFile(listPath) {
+ content, err := s.medium.FileGet(listPath)
+ if err != nil {
+ return fmt.Errorf("failed to read workspace list: %w", err)
+ }
+ if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil {
+ fmt.Printf("Warning: could not parse workspace list: %v\n", err)
+ s.workspaceList = make(map[string]string)
+ }
+ }
+
+ return s.SwitchWorkspace(defaultWorkspace)
+}
+
+// CreateWorkspace creates a new, obfuscated workspace on the local medium.
+func (s *Service) CreateWorkspace(identifier, password string) (string, error) {
+ realName := lthn.Hash(identifier)
+ workspaceID := lthn.Hash(fmt.Sprintf("workspace/%s", realName))
+ workspacePath := filepath.Join(s.config.WorkspacesDir, workspaceID)
+
+ if _, exists := s.workspaceList[workspaceID]; exists {
+ return "", fmt.Errorf("workspace for this identifier already exists")
+ }
+
+ dirsToCreate := []string{"config", "log", "data", "files", "keys"}
+ for _, dir := range dirsToCreate {
+ if err := s.medium.EnsureDir(filepath.Join(workspacePath, dir)); err != nil {
+ return "", fmt.Errorf("failed to create workspace directory '%s': %w", dir, err)
+ }
+ }
+
+ keyPair, err := openpgp.CreateKeyPair(workspaceID, password)
+ if err != nil {
+ return "", fmt.Errorf("failed to create workspace key pair: %w", err)
+ }
+
+ keyFiles := map[string]string{
+ filepath.Join(workspacePath, "keys", "key.pub"): keyPair.PublicKey,
+ filepath.Join(workspacePath, "keys", "key.priv"): keyPair.PrivateKey,
+ }
+ for path, content := range keyFiles {
+ if err := s.medium.FileSet(path, content); err != nil {
+ return "", fmt.Errorf("failed to write key file %s: %w", path, err)
+ }
+ }
+
+ s.workspaceList[workspaceID] = keyPair.PublicKey
+ listData, err := json.MarshalIndent(s.workspaceList, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal workspace list: %w", err)
+ }
+
+ listPath := filepath.Join(s.config.WorkspacesDir, listFile)
+ if err := s.medium.FileSet(listPath, string(listData)); err != nil {
+ return "", fmt.Errorf("failed to write workspace list file: %w", err)
+ }
+
+ return workspaceID, nil
+}
+
+// SwitchWorkspace changes the active workspace.
+func (s *Service) SwitchWorkspace(name string) error {
+ if name != defaultWorkspace {
+ if _, exists := s.workspaceList[name]; !exists {
+ return fmt.Errorf("workspace '%s' does not exist", name)
+ }
+ }
+
+ path := filepath.Join(s.config.WorkspacesDir, name)
+ if err := s.medium.EnsureDir(path); err != nil {
+ return fmt.Errorf("failed to ensure workspace directory exists: %w", err)
+ }
+
+ s.activeWorkspace = &Workspace{
+ Name: name,
+ Path: path,
+ }
+
+ return nil
+}
+
+// WorkspaceFileGet retrieves a file from the active workspace.
+func (s *Service) WorkspaceFileGet(filename string) (string, error) {
+ if s.activeWorkspace == nil {
+ return "", fmt.Errorf("no active workspace")
+ }
+ path := filepath.Join(s.activeWorkspace.Path, filename)
+ return s.medium.FileGet(path)
+}
+
+// WorkspaceFileSet writes a file to the active workspace.
+func (s *Service) WorkspaceFileSet(filename, content string) error {
+ if s.activeWorkspace == nil {
+ return fmt.Errorf("no active workspace")
+ }
+ path := filepath.Join(s.activeWorkspace.Path, filename)
+ return s.medium.FileSet(path, content)
+}
diff --git a/workspace/workspace.go b/workspace/workspace.go
new file mode 100644
index 0000000..0d8c0a1
--- /dev/null
+++ b/workspace/workspace.go
@@ -0,0 +1,25 @@
+package workspace
+
+import (
+ "core/config"
+ "core/filesystem"
+)
+
+const (
+ defaultWorkspace = "default"
+ listFile = "list.json"
+)
+
+// Workspace represents a user's workspace.
+type Workspace struct {
+ Name string
+ Path string
+}
+
+// Service manages user workspaces.
+type Service struct {
+ config *config.Config
+ activeWorkspace *Workspace
+ workspaceList map[string]string // Maps Workspace ID to Public Key
+ medium filesystem.Medium
+}
diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go
new file mode 100644
index 0000000..adacca8
--- /dev/null
+++ b/workspace/workspace_test.go
@@ -0,0 +1,157 @@
+package workspace
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "core/config"
+ "github.com/stretchr/testify/assert"
+)
+
+// MockMedium implements the Medium interface for testing purposes.
+type MockMedium struct {
+ Files map[string]string
+ Dirs map[string]bool
+}
+
+func NewMockMedium() *MockMedium {
+ return &MockMedium{
+ Files: make(map[string]string),
+ Dirs: make(map[string]bool),
+ }
+}
+
+func (m *MockMedium) FileGet(path string) (string, error) {
+ content, ok := m.Files[path]
+ if !ok {
+ return "", assert.AnError // Simulate file not found error
+ }
+ return content, nil
+}
+
+func (m *MockMedium) FileSet(path, content string) error {
+ m.Files[path] = content
+ return nil
+}
+
+func (m *MockMedium) EnsureDir(path string) error {
+ m.Dirs[path] = true
+ return nil
+}
+
+func (m *MockMedium) IsFile(path string) bool {
+ _, ok := m.Files[path]
+ return ok
+}
+
+func (m *MockMedium) Read(path string) (string, error) {
+ return m.FileGet(path)
+}
+
+func (m *MockMedium) Write(path, content string) error {
+ return m.FileSet(path, content)
+}
+
+func TestNewService(t *testing.T) {
+ mockConfig := &config.Config{} // You might want to mock this further if its behavior is critical
+ mockMedium := NewMockMedium()
+
+ service := NewService(mockConfig, mockMedium)
+
+ assert.NotNil(t, service)
+ assert.Equal(t, mockConfig, service.config)
+ assert.Equal(t, mockMedium, service.medium)
+ assert.NotNil(t, service.workspaceList)
+ assert.Nil(t, service.activeWorkspace) // Initially no active workspace
+}
+
+func TestServiceStartup(t *testing.T) {
+ mockConfig := &config.Config{
+ WorkspacesDir: "/tmp/workspaces",
+ }
+
+ // Test case 1: list.json exists and is valid
+ t.Run("existing valid list.json", func(t *testing.T) {
+ mockMedium := NewMockMedium()
+
+ // Prepare a mock workspace list
+ expectedWorkspaceList := map[string]string{
+ "workspace1": "pubkey1",
+ "workspace2": "pubkey2",
+ }
+ listContent, _ := json.MarshalIndent(expectedWorkspaceList, "", " ")
+
+ listPath := filepath.Join(mockConfig.WorkspacesDir, listFile)
+ mockMedium.FileSet(listPath, string(listContent))
+
+ service := NewService(mockConfig, mockMedium)
+ err := service.ServiceStartup()
+
+ assert.NoError(t, err)
+ assert.Equal(t, expectedWorkspaceList, service.workspaceList)
+ assert.NotNil(t, service.activeWorkspace)
+ assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
+ assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
+ })
+
+ // Test case 2: list.json does not exist
+ t.Run("no list.json", func(t *testing.T) {
+ mockMedium := NewMockMedium() // Fresh medium with no files
+
+ service := NewService(mockConfig, mockMedium)
+ err := service.ServiceStartup()
+
+ assert.NoError(t, err)
+ assert.NotNil(t, service.workspaceList)
+ assert.Empty(t, service.workspaceList) // Should be empty if no list.json
+ assert.NotNil(t, service.activeWorkspace)
+ assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
+ assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
+ })
+
+ // Test case 3: list.json exists but is invalid
+ t.Run("invalid list.json", func(t *testing.T) {
+ mockMedium := NewMockMedium()
+
+ listPath := filepath.Join(mockConfig.WorkspacesDir, listFile)
+ mockMedium.FileSet(listPath, "{invalid json") // Invalid JSON
+
+ service := NewService(mockConfig, mockMedium)
+ err := service.ServiceStartup()
+
+ assert.NoError(t, err) // Error is logged, but startup continues
+ assert.NotNil(t, service.workspaceList)
+ assert.Empty(t, service.workspaceList) // Should be empty if invalid list.json
+ assert.NotNil(t, service.activeWorkspace)
+ assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
+ assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
+ })
+}
+
+func TestCreateWorkspace(t *testing.T) {
+ mockConfig := &config.Config{
+ WorkspacesDir: "/tmp/workspaces",
+ }
+ mockMedium := NewMockMedium()
+ service := NewService(mockConfig, mockMedium)
+
+ workspaceID, err := service.CreateWorkspace("test", "password")
+ assert.NoError(t, err)
+ assert.NotEmpty(t, workspaceID)
+}
+
+func TestSwitchWorkspace(t *testing.T) {
+ mockConfig := &config.Config{
+ WorkspacesDir: "/tmp/workspaces",
+ }
+ mockMedium := NewMockMedium()
+ service := NewService(mockConfig, mockMedium)
+
+ workspaceID, err := service.CreateWorkspace("test", "password")
+ assert.NoError(t, err)
+
+ err = service.SwitchWorkspace(workspaceID)
+ assert.NoError(t, err)
+ assert.Equal(t, workspaceID, service.activeWorkspace.Name)
+}