From 2163aa3d8092b0313f3beadb3e7e9790594e07ce Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 09:40:07 -0600 Subject: [PATCH 01/29] ADFA-4853: offline Books catalog asset + wishlist (foundation) ADR-4853 records the wizard pre-selection + post-install provisioning design. Adds the offline data layer it needs: a generator (tools/gen-books-catalog-asset.py) that trims the dashboard catalog.db to a gzipped JSONL asset (id,title,author,lang,url), the bundled asset itself (1100 books, ~46 KB), BooksCatalogAsset (reads the asset and emits the same row shape as the live REST search), and BooksWishlist (the persisted order {id,title,url}, app-private so it survives the install). UI wiring and the post-install drain come next. --- .../src/main/assets/books_catalog.jsonl.gz | Bin 0 -> 46572 bytes .../redesign/BooksCatalogAsset.java | 121 ++++++++++++++++++ .../controller/redesign/BooksWishlist.java | 83 ++++++++++++ .../docs/ADR-4853-wizard-content-preselect.md | 55 ++++++++ tools/gen-books-catalog-asset.py | 61 +++++++++ 5 files changed, 320 insertions(+) create mode 100644 controller/app/src/main/assets/books_catalog.jsonl.gz create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/BooksCatalogAsset.java create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/BooksWishlist.java create mode 100644 controller/docs/ADR-4853-wizard-content-preselect.md create mode 100644 tools/gen-books-catalog-asset.py diff --git a/controller/app/src/main/assets/books_catalog.jsonl.gz b/controller/app/src/main/assets/books_catalog.jsonl.gz new file mode 100644 index 0000000000000000000000000000000000000000..a7721a0114de59296c3cf672e8c868048cafbf02 GIT binary patch literal 46572 zcmV(+K;6F|iwFoohhu62|6*@%Yja;?VRT_^Z)YxQb8l{J0Mxxpb0o=@9=6x72y3HF zSDOKtd_SsJiFZ9vg+ZbVw@0OgXQm@F8j<07;{j9_O=cr$K}j3ROk-SVM@dG_HQC5U zq&TFRC~kVI#ht%E% zG6>}Qz`GLB+PhOh5GN{>(@5MW_+%i$IX)*te3wQ6zF8#6Dt`ILfBf|HbTm&B8BSz0 zKT^^BkL5(Ehxm^Nog=wQC+#DDDdsZ%=0E>W{-LqC(``3)&AufgPyA%1lZ|m%+Uhr&JBD7V$=W;hrw`wI@9E*f z?z@Gcf7`M1N(PTUJ<CB%>`}xtr z2oJp1B9y=0CS2ELQ@=OZG3k!3Eh9gi)06P$i^N)a$B_z?pa0hb?`-J@>tAmYUDo-O zy3O{ENjJ0!6hEccY2eGm-V5SE#w#fzdV6>D5`D3(@u=wHPQ!Lr)+nIY^=<5pW{)yV z(n!XhAJS$FY3tI(XS-&eh$y0cdf>g4Pkvm(K@cN)qn{{L$2p^|#;sdOO(~=aXzSirz#mSAk4ud#dOmhPyB?)7yH( zdwundhST0wZ`WY#NAGMM$(PX&2sloX~ok zOK&P-iFbY)9eJ`3iZxk7jcyCo&XH#|Dd%j;h_pbdZ zwmX}vB432G$%OUZX*JGhQNE9-BC7Gm`_6-|+3D_{bN*}=$fby;i(O030$(Ku-i4&U zTW9fgx-L@&o#HckwOpwvAy>kW=~&`GITKMRMEsiV@^ zyOwHYAJ5P8G|5iq_UU$a@8Kl!m*!+nLqA?DWoVr{r;1{(wS0^pC=NSctC^}{8m{lM zt=(@ndOOCw^%MGv93@$$!8#F41asVH`k*AR zRcV-D-NGXJHuC9JTW>#Iiuk}A1@aR;?5NJhYP#d@cB`>_qQ8~2ZIXo!P&d|Gs>wvg zh;9{`RpYhN^<1`eo5NjOuGZdL|6Y3k?$7=_i~gI|wiWXbN6d{srPFWr>+Qm|ozKW} zgr||1rh%|e4D|p@j-xseLfh>FTU{%(AJN$m1QZ@#l2el&mi#KPPl;EP7}5Qu3?u)c zj`wiK_k+%`RaoF};EpL+{gKj0Wd#Nls4VFMaelVm(LT(+)G^j~b$sy9X==0A9u)n? zSxAeAwjp`6RpeVPK3#W8{ZSxm_N)!2$Kz#Rj2E#sc(Ojvts3O{3y5^ovp z%|+0q-TB+@vfCshNVwDKLm=tTMQiWe4=t~8`$UO2*AO1+By#T8*=rWw=B|BXN;LyD zeV|PeEM;sTuxljbqbOOV5&7h>PFfCQ+~?IV8hS3HnIGa<)n~&wIbn*f;rg_j^|bc9Ch73Db#5E!h`jvs5qINMAkSjhR!6uJ+$K~ z5mJ1aeOQTH{sIrJY{7*()ZVXP;JbMvsYH32&L@YC+rOB=Exr zdrLp_TZK8!McEzhW=od{LLx4>-O|!~twNmm!aJu0IU{4|c6KiVTC#I}y3`2uV2C?3 z?G1LV%uWwF_}D-C{7!i%aTyMe88yonD?#T;6L-_0gy8V>yIu2t2p>ZA#Hsb7PGHRz zI`h%Sp!*9O{|1Fv+ArP>ZGN@f>vDiO&XwC~S_%H&{pCOV_y78z{Kr50AO7wy{>*yy z60iM={x?v1S!DKnUVPE=#5J7;4I15I@?1lw? zI2NDi(VwmSsdug9azAc_4f+3RUAWn57c+qC$Pevi!J|t{2iS#pX2F*qQ&#b zmoswM3*;&b8Pur<58dHSbJ#6DQ=LtDcOnQ@Vr9n;7k;o5;Z`BoyOt4ogF5e=tZ)~E zE&q3gAg63AQ~W=rhd-i~8u)39)(#m@51fy`rL0p6c6*#|t6|%hh^2~F3l;m8g>S_D zgTT8L;pZAfgqGXUP=eL8{GY#E#@|w)?^Cpbh}yD=KY+&ER88t+?!$^|x%Gs82-b0I z+woehrxJE7Wvv2u#k1*IcHD_bS4O3~>gJ@$|P zfJx43XKS}ccahTGc$5*+(FmapVlj%g#}x8M6nNjrrJ|Ggojw<{-FT;>V78|yoGf%Z zTDuuB_VLQ;flX9GANDw@r*|EuBCrEpA0^T;o$xX~qHRc-h14&HqA5A2=y)unNBUu0 z$0JwtDw-|J=g}sQ(I$Nr7o*;1@8HtaolCgaXzxDOy9GTgFJyR&c43^gdML0It7Xrb zva&7C#XBg0Ma;;XlHZzgqtvntWpN6N={|fqDr&6BK}8DIZxul$?`Mca*2ti5B=QAwvYWnmS}1QS!cZk1t&kaifFAqY5A8L530GXFdERmZ zP};+xeK-gdEo4Y`>@TLlb=?(}rq%wqT5~=V&SHK_WATVz`53+9=BBLlxDBHX6)g!$V-#Zpb;>U@oQS2-W$nD-dF`{iE!sa$;1~L<&8g#f0Rg=;*}ryN$kD!{=tNf`!Jy> znf(5f@)jxD$1@5A1f4!h?fGLV$RXi`^}|OsJL#ranz?9)V)?5B9j&&T=j+E%VSmQ!pcgpYI*>5NTLv>p~ASZ~$z0ksdcsNuR` zfYZ1^t23|=+bWRA5h+|SRr@$gEYYuhbZzn-qp}iI^OEo47;30_xn~H zc@`#dB54`TJ)E?oG@eA#Pw+kM3W^}fEiHX|l1o4FB!!zxZxk*Vm&vQ~GaV4(|NgIo zM4~TpA<2!A_oCPnzm2*b-Ocy-MF9OM-dr8i8d*Krj*}@M9```Wb-~N;T)_Qj-y}LB5@uqMY_jB3x3z9a6 zcTMLI@0kBoe~-s>NAdgmKeNwR@&@o+y2!-ETT1H_awkvU)p=y@e5Y&S2P46wm8Tv%76&E%u3efFd z@jbpzlfc)dXoKl_X#nz+y$2Nad0S8)j(&L(g5q!AE~DMaAfTmqdE{M?8@ZGbm(n!U zzgEGzT``{3>y*JDZx+L`F%@Eg6%y~OUfhQ z3}&&7wvH$ixS)VwT?+x;t8m+m-DGWN@JSDPgg*&N)^)x}%iKaHnV%lyAFR{(A#0pF zui3G>Kf80b*(m6hp0mCFs&lq%oZF4{dqqdK)g0H$_Wh$zFB{m1*n4&O;gvOa`@UXJ z4K}!eRaVA+97yq?VeWgw2sPZ@OCn5N-p zN;<@pYaqR3;Y|&C^7!cFs7^TMtalEr7T+oPmp@7UPs+~OPEczOQF|T>@hE4Jpq=59 zw-3BojYxm!?&ozZbUQ624C5?C?41Zo#Zle7Mz7)f{0P0EJ(76i>;TPwVP9*rQ}NoH z1FL$FCZSk<8?*c7-HR5=h;9Mzo1ztOmHbmOEtgF*RGOn2~F z39eC%qA3#y9C=N?nO5=C0LEp6f^SeKrGz!^)2&#;yG^4o^CaVGWY5rY-c}o6O&4|_ zW5s7D=Tdh|#(|WpHAW{2Gd!=NM}FCNA_nFpQDL3a$~_U*agHVKZ}gC^>K^21Sg^u7 zKcvv@klx?nwNln(?3EuN45ht*?Jz<*WrlTRRBKkF;RX)2N{*}7X*j3n26tfL-Y5p- z_jr@e*0l!cS`~+reKu_z&0G3iy8ht>is*ZSt-2wi&34=B(aKn@$a$V~&Ek6uk;*48 zFJSm`^8O8!<^SI8wWE0%Yz;1un zcbrl(Hcq1S=}87F6sJChh<&{P_#8?wHdWn_i^Ma$Ui`x`X0L59ow~o*#oJ3db35!n;W{H3h`;2)f9ck1Rul{e?Y;vv&PKvLT#K}T za()ZPcUbo!mv?gKrhKrr-fqjU-9KvxEW9ZUj0z>&`*Mi;zR*nsO||L949z33In zjxnwH8s`!WbMD7hYiKu;vXJjeV!+j(>Tce#T3he@Pm!P)gfAWX@Xj?30vO_c=_P=~ z8{r2}Voiavo{O>C+A^N_3A2d2IGg#&qD~|^aN`gMon|pS8QwmLdsu@Poko2(ro`d9}jLGqgJcgD_VDhCMz#LGs;BgY_!yr zWN!tfRHuUeNhF?5V!TTc1U9G-P{XBUw5kTZ)LE!aH?)CHzE06SjaLFx9UDl%jTD8W z>zxd^4SU91B&yDsDcRu0ITcqc#iE?sF~ZbG&|VlR5DVJm2i_YQhP4LSTW$nadoUan zU;c?$uDo+UqBy{hXhV%v@JNo{yIO-JXuTs>CCSy_ALNPcaT;rNDBk$9P^?yR-#F=xyA{#zG}=Y46|oPc zz-pd?tI%Lx&cf+>8mN_!GhgU+L5cQM=%qaJD4>}!y2${UP{@k?Ps#d_{ug7hk*Q@SBa{psJ`WEX8R2wGouM`8WxCbz z&Z?!Id8(##T-x(KXr_pW2W7|wgd3=94UYwroE5)DV^H+@6vUcwp8#A0zMfCZmipOp zMS%_%-gjrMo|N~aqFdqL5LwE|<@hvGD=GBJH=@4+f2IH4fd(XxrabF!K=`)`h`DK` zV$>Jtj_|n;Y7OhN=z6qN2(3S;U@o>_`wfsM%OkNpJx1N<47RvPj3vOy-XREDzP z?2{izGsz+hdVo{Q@q!jJZ(NfXydqPXIYjI@DS8xiE$`M}S}*oZs6J)fWGjnm#yKdqY>l6*h5a<4FG%bQd)9o{i zKlb1Su%qe;<@h?SQNF6XTG_Jpv3_1$MU``(Hg#lo{l9&$=5m8R!ft-C`9^}9YF4X) z*{ayoO?x_dv^C`rQbe_sF#?M-N}AMi{k?Z?<|j?VpMn!Q@ZVBKB)JfW`AB`{)2f&* zl(On^$5KugJUNb;Wdv*9SzNE=$@fr!z$^i}{19?LW3&RUC>HeRHh<}OA)c7j;2v`> z$LmPdf~t1iSeMqY+qD>lC|Dd*@J0dArO8@9hA{lxkLYYa)6{VAtWAsFbn7<(V%X3% z{7SoFn!DlHv?f+B6c`wERW@rJ@M_=+p21W6=G804;U9TzRK$nPXFeO)EH|Q}UrHW8 ziKFHXSQYp~jw3P40C(_rf9fx5%>c6rZlM7Sx11$~++%j|zU(h?ggF(j-$L-;J_$V) z{0`@VH<7dkw#u_e;&uMO;XWuD-qF@mfhcfZD6r?B1QPRAx_KgNEeJR6zu9e-G#<9w zbZ9n3O3XAY>PAkLuGpQ6DQ4JESO8Vq)XT>N;{!wYLSOvR^ynb_7n2%Yl0%Od{t7T1 zdZmZQs3>88lv3!GX}F$}4_yti$;1mAbl(%;hjVjqI)4l@CkXLe9r%#J!Vo!9{14Qa-;4#{nFTe^r*tX z2BPna@TFg&wqn8OFIaB|{0=J>YZg*dL!~E5TT}Olv6@zS$9Sg{YX-z}7z~K^cUTyB zmii)>wb=?AF>I45-h{QZTzVDb--AZ?@3j{O1KgS^t)`V|tyiYVzz=WZXIQpCeo78= zLOOO5yf5w|zmUV6<9#6d;?YlNtDMyl%wbENInA(VjS=Lx@lHO)Y+s>_ejMmdEuKGj z=IDkWWmuxtZt<=} zkbLI$*xeE4>s}zgq)o-}i8{$Ttgo1fv06`{qKv&WihK=n!W%DCO8p8tH7^jUy{4>!y0qFtt!&wZlhhUJE6n5BEk|b@W5c8>l6&z6*SHkk27Zs z@Y3aIpJPE${o%Dji}tU+L2tZVQ{1xs3zXT4TDcRO>rS|BP$8c0 zI|_;*kAhr3NSBk;pm4xhb>IyL-NUANEqhqVg9_|mtJUqc0=?K#~^mRJI zY>|8I4%)@1%iRF)N+rBl(1YIs==_*IpySA}a11?MnXy8OqQeOVXUEd6WL{7_jbs*Q zQXLF7Y;{jP4lH0exO!)g2>oVxY(fY8aw(#W&P7I3KQ7kKlO&Qe1Sxtt$K&@U;WKL`u$&d^u_=AH~-lCoB!(f|K@N0i@*6VfB(;a|1W?4f6@Q_x4-`v zzyG%${pcV4{$J9M|H`V2gCjbOAAJ7+2mf69n3;IMGmO-u)##1VFN;dL6;<+<4Y+6a zD9v8=ZA@Vmv-#=BN<#r|0Nqc5KfPGYK7tnx>RG|Cw|R-7U|rSpFo>!fdsGcwlsUmmi}Wyr*>Q3mQh?K<+O@xE#zjcx^PX<Q(;=4mXkHVzTu$XXt(|9T7IX3e%K};yAq$c5_P5M)cE|;J40@BoK9r8 zp_)S&5&M#UhPIBRpA`pg=(7i91e{-1j$-@VpozXUt413Udvs^SPb-|fQ%AOZCO*uQ zRd*YG=K2bZY?PZgr!^>T*#>fMh(|s_#_grGrg@b|LN6FP#eeggS6M6;gIhLyfwi7V{_LiH~akhGn zDJMD9@42MY{%mM+SX416-71<3&JB&^-9oRN%6ocjx(M(p2w0$qmNY#7E8H2mp;JH3 zW*bf=Q^?WhO{oDO9D49mUihcrMk|a`KCwU$aF4aKQQTIAntcg3FwiX7C~A%=UXrnx}{UKr*jy_RgBTvMS$hrAh)fGao$3$c1+h#@-c1%vKm_BaU*VD3%jBU z+idqO?daPle`-o1NpZJ5uRac6-A(G#Y1lNU{>-<6{uKp#*&KnWL(kK3$NJ5V#rwzu zc|NAavRA3$GYjz`;}snRa*yNB9XBClyVWV7H{1>Nc|t=U1$zqKVT8N8+bQ5Ka`kI? zJj&RYUq^4JTR}$OY$FS6uyxJwT8daeS~4$;oQ2asvEIstB#`A|>|`2Xr3i`-HEV%vpgWGVDHvHVtqNH8 zJc4R`VfRpezgsL(egI%A;fi0$cGcNh@~M)ROgJO>sJkU*b>iv zFrzm)={L<&Adu;Pa)*60>sd6_xqL)pacFm!i7ab zVdH$VF3@2wxJJgqZ*>}*bY-_$W!MTWRp$b)Jlnb6u0T=rOT=(8plE*hT&Q9?b0wyy zm=v95&HFk{3g_}14q7FdjP3`&Q(+9YdYb@7JOe!mUDr*J+G~{YVT0;tt5mzEwU3Lt z-({oe(v;6;)LDNr8-pvvYY}EuM*!6Msut%bOmQF64Xb;-8$685W-Xs5eBetv_h`3g zky-Mf_d=}tcW;*jRYZ+LrsEMw&Jn33<}H`XJNQEcw% zz~et(!zbWgom>b#YZmMdMX3zWi%JN>sbs-}%jEF!OmHu6d@W>i4RvG#oaYv{u8@pZ zv^g+~K+nL>^=wWlA%pt`SuQSP;VI73fKj%H&nP{s5zykU+^1lPBA8aMy7a{p66ON8 z{QREvpfjD*J28u&fR(Fru(=c2PKnRS$Zzrqw`0_u_eOuysqj4QMzeUlEjT#@rIb<& zj0%Q@mhPUq-Y}<%fqQp(=O`)$B)o-x1Wuux!{e=((UCWrk&7fJ;Jr>~alL6avc{P$ zw5=R5L;ih$B+vTmWZUgd#niV2+PMMN982;QHyQnz{3!iB3PKT!XW34QITrMUx22t28;BXFVu`3soXLKFnSA zy73xcyka0^P&P<>CBv6Sq=6g)^sIZLQL`p7l zNJ+W6-Iv1ZdllfjW}{=1m|`hig671m^Y|r;D&y4uQHp~2dX#8<`k5Y@Gn}nKQ&xB8uhV3dKzDIDsRc(ve5q=QMwv*X0j9 zG6ub%&G5}NOZj*k?{?tbAy#7!dJ*f@`c}?;;9xOD{bc^2uV+->Pkxfq1R|>QMe;rU z?O5d#R6NDbT~_<>*H42_|93e3SxFr82K7Kn{OH}&gAFUzI}yuC}|?t1#bVJAl> zH)P%wFRK}|@_9Fk&J4en1`iCW;EWh_zlieXW4P6_L>J!ZzTSxG!$K^Nw4W82dXr23 zcm1?a)g-nfoaZ8p;k$5C-w&H5m4`e-C4X}bR#H6>rfG{_WGYzL8ZArf_)bMr2C(A@ z2WKI@GyMgmZ)=J2!c_O!Q5Kood`2Pz@~TK}_~6&r?D@E%E~Rm0wV+PV$k+q#9Gq-8 zkSMT7kbTr?e^!L^y;iq`M3e_s*W0tITBjnKr|>cxiPD*z2%Dk%4VbFLGSi(SAMsd6 z9f&P-4>EPE1oqs&PuAYKSfb3Hm8@?hnx%#D!r7A_8an1WlS>_Uw%O};hQ+cLSB(y7 z1+OrQI?hx_a}rKRJ~H;hDV+wi?0;A#^=895?g_|NW7xIBx_gGtfM5{kgF{FI6y*G*wK z$)ccVcg0S*F2p*3I*t@bnXq_8m+duLFB#}Obf^0rB4LPIU6Nx8WzyMI1~>!MpC9- zEeOO#GEeU)ZmG4Y+($P6oh=vUG)J3KB)R;1LYr22v95*d`C&y*+&e`=pT*)D@HGum z9xQoFKf$CJs%s1mpOc69G+k(Jg*<+zXRcYD4={|U^^BOH0T zhPv&Fq1lc_CJ4q9t~rmM#Co&x&dKi7R7pgZ%vwFlRRSoP)>5q%iMV+Mo4tW0mzR@6 z>cQBo21=oAx?0I(Ka`PdhC8PiTBIaM+n5O;z;vr-ZOS-3$k%(XLWq$HL6GHqC1!{+*so>$ zlClsCv43JUWm#)TnT@GH61FVBQsRv!Dt++vu-RxDNgZby)8B~|omvXyP_M#ACa7k_ z)qK55Y!cisp?!X#i`JR9Wp1-t!{cOeMc81G2<9{cAdxeaVDInmefBLG(OGt8 z_Mjp(?%GhfQAYS-#&OEzHEtB0cHEFVi=I${;9?(tReN%QEpp~e!>+~9eira(E&9}6 z0J1cn{0aT{@#nvcFh=+#WWDhhB}y=*;()bS=T5{VXUw1sPaj*k+Yvb@8bU!UI!eO` z`owSiI&B2D$l1Z^ShRy*p%jm6R=|;3b}5=39!6vI_!ZBe*h6nx_F1fwydOV?bd}Eq z&8${$u)fDUD9tw(Zx=*Q&~nF&#ghMAHenoygg&&~8T~gblo`C5F{?V^F${u9ZFRBK)y1F8%o; ztC8>ND1jC3Mq0-TVNL*E2ZwE@hOo`RY;(g>;5%hu9!k<{O-py&VM(*wv>IpEezFvZ zp|&!e>^#XRVP3Ro-OAaKb^P5@_%#!uIzZ{RPV&lDRrqJ?scKLybozWIWje*1wmaksclQ{zwz|6_(C} zkJ86$?$w}O!&6o_3{a#%CY3=grJueVa@^}TJFdBR3xCHXJXS72Q4_QDeb8u#4}LWF z0VIrSvDvXv6loYm9u@6!x{mS;VyO-r9U7--UCq}7BI!G;clg9v6r6`H{< z$)+|l0S*Iq!@Ndb&W*Wut{1csd^Z%%*4eDp_wpj%rFfKes5|lS+yO?*A{`$4pUigi zH}gh3kD_D}ttoVsVYrSp?t#2YI<@nI=jxxZZB+}nUjnAq$BP+5&_rtY^kW~C)4H++ zwhnS3C+Y|ZF34u|RTU4Eak>pRZ zo(nj(LmaJO2e%buDGi&6wJv$pIYcY8)M-_fASUNfm<$FlbsJD4$)lz)wO_h%W#NA~HMZulTM zcg?j`a>1|uJsFZGiGp+G75-ZdPHX5@-EM1g@Lx(OmnXxMw== zvt)X-N30YKb~i8E7RmROn1=$(EfK6OhR82W5T%+VN~;SZ3SsW1dyl!ja6)frp0-qt zwgFalyWowSE<(`qW&MIN{nb^Srf0i~N!w;R+^#skmP`5#?>fav#=zcr_$mxSL3@cC4A^)VIF7yj?e~Q&4qadA?C=tL( zt|JU_Q^+67TH-1&++DqG4U6vbeKhf7O%h@=Iv@`7(>Rw|8!c5W98RmM;LUR7+Ipr=U^6&;r#=nn^n7MSpWvaCr!TonY~1RbBJZca+O@~DR60|5dyOe zczGo#5Uhpn#$C8O*u6ppR!iJuq0AOTwrL-hajBr^o(GJYfVoa5yXP*#LLO#!u~5!( zE+)SBC#fGMK6!@F=hNLB*|fkFy(n_*LZVtr?tdny{?cCYn_u6yJP=>1T~py$hL$qs zD3sHgN;Y~BmvkQ{kU)Qqq7WvC8HM!6f&x;6p7_ntfp-JWdyZkLszaLqo`BxG6Mj6nz1Q^YNQZ8CQJ#1_vQS z^fK=TO%!$?p+MtoO3ylp4GJ`7T`4VSiNCf2gCl@(OU5PhhS0wzGsu~q_%>G#DPWGt znJzgDf-GP`<^%&%%M41zsLsG&)xp>FD&YI=LAzNH1)(jQBbF2eXkaHYr27YkJrFghEF zEzHYlT1TFPZ@&VYP+Cfa?#{VLK?EJyZKvm#bLqGVH=Es_1@Hjn)y^?!SGGfKUFOry zvI-}@M)9Fm9@pztlpPww;+O<7P8ODo)NfNo2i`4;$eIAFyfzOmf5--1iAfy}9yX_9 zq@!f88(=eD+}!9t0uobCEEUFWZ&Z*R-Yul$UT2j2Y@68b&#-CeA2yU%c|=_sB>*JZM-X0NdEUTJIHppvyc-0 z0Ru9*k#ipkM%hq;)sg_s3HooVl?v8Loeo^r%|$?Ir`0YW%_9Fk+0tH`#otq^sX={3 zLF_Tb2~Uxng8jbO^*^JP8t2D&9#7UvlidyPes5rrOy$6zXd$^eq=yd_RI^YBXm%P6 zUEt>|p8^>`m$*V7$(5cOVRa|(NktE_qg0(89%E_t!LOFdauZXvySBQcn(g#gbdBs% zaq(6o9HIyy)0vqjB!75_qa`C-H*+4T0{vG{IOIy{!)-NNwm~xSnNdTkBe!dR2-tjb7g!b)&`! z?4_j0k3|jfPZSKJ5A<40A1FjJWRZ8QA%R$c+4vTEV8^=^4>HUI0&mpx!5kZ-2)ckk zaF}g7ik8|Df=>XxhTDP^>6dCdn(dhn&esYldhWKwpl=0}Jl4uZMa{3ap8GP?;MK82 zj}5VLDS+Zm1X|M3L>BDL^RHPOw3bY7-yLwaTiyPyPiKYCa&6Efb0ei9So@O-WgDFb z=b$xg76iQ3S?@Cjj^&W!Cnl}-KQZ^&){k{}2CR5oDfPF??es=MmC-#3&h!E?(IuR< z>7nW@k>9c(&iqGzmKwtT%r&LDWiicYg;}3PFM~a)rSLOxm&k{f;+fT#-+{c^*@i%( zm>m(Ltz1J-P}W(du&bi?&~`I^fYS==b9#fZSF$6T~>K5(@&mEJjn`GI0a-YYhwAbdn>%{7X@4sh7|p$VuD zyz3~XGZt)Wn99c&H|7W9CpkmVFs9S@v!32Rfb8-N0p3AI@q3U19Cl8pBT$dg<}f+V zRI}Kdu@B05SizdH5NgU(+3`{J1m1J)Hh#2j9;3cBR?7Q_5}q@Ppn)35n=!EcN-lF! zQGB3$nCKnd77J&JU{wbs4BOnLsdmd2OFiN;4nthossqnKAu-k|mKk+ES5dsKCv@C) zJB;RVP$In!1%WFOCwUW;^DLuFf#}<0j6Z0g+IFkaFm`Z(YZO@%P`B%Z>w*vquf=yZ zaF^fu&HixrtOn&iV2Qd0@FXLa0a-~&xt2lsr8%F|Of&RNjgr*&I+{IT?J8Cz-j&>5 z=9Db4oN6H9Z0mB8w-{~vwU!LVWo?1bo|Wc_JG8giC&gk;Ja=ineB8JuKJzfFYue32U5bpsl@QCM-<#W zWpg^WQ3_dtLlai%a?1AP!j@|~3y@OVx}<`lRj>qttG!Nzs$YePZdq^>Rwc_1Vfqdw zdnW9%%ZPEz{U|s?V3BIYgslqYl~%`U>Clrwx;IguwrRNeId2&4_JG#zxO+L!OKOy9 zd@*XDp=TOP{5>B&g)-5{5K)RWym0Rj5*!#VluH?`Vg_&Kwa>S7i~G_G>sRQIH-`<2 z#iN9uNtXyOBJ_r_6ON@;<-Hfv*kAfV-c6=AN6QN1trVz<5W%JgIrz7Lu-V>J32?yyjEkhq6XAzHf)`DGO>313b7908=7^g7VCZme`2%MDP>fB6;ZMxX0!Fqq`l!< zg_uAZEkT2SS0EJJ&xk$jtSGBA%6a1%rp+@2C2;#^B9F(_E1;0QVjOV?osYv~G4a8b zHrlFM^WiF3O#=VEwue8%dCa}@`iiA6tm1k0Th0RJ__wJaMKVCs=fU4&71LcuZ??!& z?3OT$N-^@$0?nt60?ZK6m_vG7i+SZg2YnRGaz9kN_P^7i%2;SPrD3r$%mu$BC-tg^n$l9M zm&843=3Tv(5oKuSQr)g9H8;J z#|#UGR!|MwxIdIU4$nb^qcCs;ev`d&3Rh6&SQLM+qDmbaN-$On#?{xHoWq6Pba?HC zd%c=f%CD5NndL;~^0tl-UC~*h>~siK1m2Alu-D1gU~EMIZgC^95ZMCl_AC`LeRkSn z2~PpAg2Y}#C18x(M>H%H*_DWXf`#D0Ccj3@QQ8hI2(nOfUFQMf(p9)i8xYS#)@6)G zH{vPq!{^}+$8{^1Y}!1T7QFx`8PnqKe?D%3W(6N6rS4W&`9J>CKmULJ>L35@|Mu5^_ZR=Y zH7toe{HF^)Nq3q^pBG;!YTvFvl(hQRVB~HSZd1kh7;X)qf13lZ4fgIguRiPvSimM$ zfNa~H5;bKM&W$o6Gjs7y=M17a{C@xh=9Jx(;UoRk2wV5eCcOm zd1Q55XI;XEidh#6T8)r3A8vj-Y;@vQu?5Qe8ukfra7PExmcBQ>pQXZgL))?Y-JS(ti3Bu5sy-0 ziqkjA{C&nfXWau@E*#X}9@S%ib+70~uUE`2mn=Y+YkT`4NV5Gow`)0*5TPeOt;n9d z9io9wc~@x8G>sXOGTjh3FQ&kGDE2#}G$49Q#Y|7jIoYrj*QPEirMD z){bI4v;Im?9oFJR?)54_FkPz|d!-39=oYR-xHg;!Hk^62f~{wdh?%2lojxY6?_PXe zo3&As4o`D3Lk30CmbM!^&nQSoR#Y_#ih~ue==2ubN`5aX_cS~q@3ZpmTS-fypt&aK zGfxQjaBKvAy~=#6B3WwmEmdZ=@LC1zS@BSRwM_P7qZU)%&_)&%iHd$w-_TBQ$ zbf#JJgr`2X7(k|1;wRyyC@@oo?Tsd2S4j(OBp~}nEPY4ILYh&cUn!shTPg@iEYZmu zz*&$tZ=pP_M07IYM6_n|!I!RL(hp^W5!XCwVR}VNuUSmUPa<^>)Jt(5Ll1XLk!l>gUq=p)uU(;WX%S^# zLUC;RKv4`TLD}@sW)R=z&f!mk{X3ZcaNPPC8b`Z{GAZiU9RYkg;MyI-d%fvv3nAM;I`cdwsnO>1!4y;K0mlyzgUx5Jt zMpcAw)~HXh{6Vi4U5P!CD^|!IciWwovTe3ne)95+m@^VNO|7MBr%DFFdAcr8+JCU z^`t`dQFVrXEmDwu`{9$w#PNRRT7X?nXj8MOISo}7HtO#qo`%yu&Q=g6X`F^@? zr~|#G-tM}8|9QzuqVGRz7}<@Sg0ukgmNME#zi>n9RH4jIo7nW1--s|4WHhtHvraFT z)`2;11ih~a^vK+zWB81kaV~!AKP15S^Bko@#W+~g3(XiXm?wtvBJ^-5v5&q7owSdU zk@uPM_bjVUCrWwmQt}?4)&Ms>R3a58iOE6(JM}$DhRjL33w|upCHo!iLSMXHFU?D= zBdN#_+KG>E*@CWmF5=VSiEyzQ+%ZlVId_yq=}bOD_{3&Za1K`tMuCq9uV%hJyz|2c zJ9T|NWC5&jr(Ml%feDwpP|RHwbIzn$Ox+C5qbO9mmSLXjovbyB51Xn8HHUU5H5VSs zWeZNCG*(8OB>P9E+2nMqSO*+od>mV6ES4z+*2i=fWna?&1T_gXE2N=1R!(3P$+eiE zno7x%rg@Y832>vs+l3EEv!vw=FrJjGuVFru-*DD?7{#$NP9F~H5@NO@@$N+WAmfFA zmyf|-$z-T0ZT8M#vjOob&5FL42@H4ELEGLE=-$xj>tT`)e&jQctCiF}Aiy)}@I;|w zWaR9u5mamkf2x@|ur*aJw1_(#0O->?7G$he2P{O4Ug1lgFgm9<9nQFHA-FnpN@1CO z6yO)@!B3s;zTfUO?Cz!+!R3J-Gq1NnBI{g=w$Ja$`G(vW9$T^nJT3+pF&2;b_y%r{TM;|M8VP-x!FEpzIpTd-`v0mYHzuQj&k+%Af=5tTAvoH^riG?{>Sje1x6{%T=aK;=nntl4yT(Ly4P-%C>dbcoFO`oFxOq6L5t?7 zROo344`hNt4HiMRpvwDN`ab!@Y{n&LWKCBpE}1SzU8VkT1e;L-SFv=aa8cS=!2|NK zxTLx8Rw3ZaKtU9uzR163;CrpSwCNU|YPN>fEaiy`G4zh2&d>jg@c-ahwlAX-;pbMmGmWuYFFc!d7=-sL^rrqR>YF`m`vpsDkaz z%6N4)s>vttL?@bC%TBC(O?-L8u&}q_)K1pk<$5M-754|-D#Bz7m2)m8w*1(^4x8QP zrx}jZ#Ln5L4#;t>paiwS4nPuWD#4@>CtyeA(fSgk{H+S&L^1cLI`HO^xUWUR?1uWZ zTm5!PHPoL3re##@c^@GEK~j80(b+=Oam;*mmubUJ-+tG2O0vCwIE7o?FladE~K}Q4~JiSJ|8ms2Jzj-EJUNiUH=e|8J z|7`27BKjU$ASc#t*;H80HwsStB@;v9J6*ojQkQ^kWp#}GD!IU1_Y$?$c8qi$CWAN0 zT;31K*?~X2di9D92j#W8FR@y^R+DSJ{a;)-U%Hkn)OC7vy|%khHr?>XPO@Qy-HPsd zLwp9o!WWxw1Q>BBTdYIG4C|cHQmbED36bI6dE{{EipNL<&fmjc6&b&JD z$FP!z>o|=bc4+Nug9O;N3S^dreK^O61@A!>C21%((f)O$=8?f`nKBDI96Nx1W@epU z1;nA#uo`@$&}K9*u(UkLNE3!-;r*2eR9I(}4OUbPC|J-pl*)5urI#BW$uYh6=^`I| z+BAbqQDpZ<^v||?MzzKSs=#qwYi5Y?;^!h-B8uEuB^2b-|JS@lNcbp{1XdB8xdn#8 z=UpW*(hXuQp}>D1NjefW((XQhK1ilY>0mLChz=V^paI(DvZBi__nFby{~bgS1K7EhU- z>9{?fGkkStY>m$dWr zkXDYq(En-~eeQ<=Byw)l93dcA^7$*%ZmPusGpGpg$R!r!6tm4XUWf+?empw_eJhre z=ipPp#oaCBfkplC7LAYz2F{qY`26SJ>CWH#2*5JXYXUZ&WhmZ`o&X*^d1yY?Gxo=mm#|y=NUSzZ!6p5*z)Y7|RcCs@hu11fG&-?Xq*pZ$*tGKTYdN2>Ehe1)9bjTGQG+dtr6q;PeYZcaM z?yxED-8JZzkVtJ1y9OZ~j&fM-?)=6G<6Az5%A5Lurshka#_=&UG$A`EV8|oHm@A(^ zwNNEyUa^~%Fcy?X=^?#Q2{Ip$q?!dqFLTRc4KWnM4 z?*lL$7sdc54DSd-Ux)v-TEP7(#e`P3)BlC;U>4CKlG`d9WQm~BFwf`cL8SVlm@bmN zHVVn?iV<$>4d~v8!7+>yx@O`}_cX3s@vxi2wxvu8G#zioQ7D2nIVD7Q2iZ0~OV&$` zOx+T6JC)2pjnI$V4%SnC0*z?+96H`G*4;X542zA1YeC62l(_OU{Qc*z;tyesTOqU4 zXqO&^-e{sA=`m<{ul|GyAb_+wWQvEEW~`q**s11dMZR6oivYOv39w~U*b{;EpVX32 zV|(4Lv{s4iV_reu&K1eA$A*UUMDvd8^De7DUZZG`20T&dc)n+{GFPp8Bh_}h9AQhXDFgC0p0qpfHhYMhVp8X$HD^l{C2O` zbZW7jMq;jPaQQnKPkgfLjA^|n0;m%v4=T0qRDT#Qhv18Kr?8@ zNI8P=;d|n(4xzazYwa==5fPpEZR&^h=*&CC-A(_2HG&USb?mF-K&hq9 zKgRO6m>FaGbKS>2QIKqpZLafYMFkApgZaINo%D}b9FGD3@~VpsvzjbdFTTJCyiq@V z@UF3-uWvOz zwy=DVH2nHlK3G&iWZ3-$jf@*Po2|Wb^{LSIr9Y(!#3MxJd{k_{5y4E=;sAD6uKKhs zEnJ4lt`S^e0^ekzfdDLVgA?Fl2-8Z0-q~{HM>3;njx&~>m6!`18|PrG&0hFJ{;uS| zYXj^=v{3i=2j~a9=Eo$}3}aLpq%CU^J>zLO;dcE_wsR#nKH<<91wjr@HA}DAGQgbJZsg!5 z^%d&W-?|lS{^Y6q4jC675AGg4Hzo*IbRK0xJHX}im)z_YL8uY)u-dY;?3u8-b_UpU z&wn-hHpOb*^r>LdmVpK;2~#4wlaqS7}zox@5xB{FPR@jc`K-M zdUQ63&!2tk4kF3P?*~7cL-Mu;N*<=T3m^TGS%(~Se&us365i_H{n?*qFzrqYAnhZs z(LT^L#|+XQJaa(Crny0Ay;i3r*$~Wf{&gBAY(qs+w4}F_(c&-_CAkKftY3ms1zaZ@ zUb&&*8O>(s1n&!|#s)g$-I8*hAqaB;$)6`F=(%fj-S)5qJZC)q)&t#;IzUti${y%r*XdtZ%s|+))MJVfwq{Rm!P)OmbqveBdx$rk`mxp_-V;WDP(fnSv6`yq z;!&lMpCGg3IYvK|r-aQPFj2NAgeOdKH-Gz9rz=-_%*d@hf9c(&*?`#}I>+;6gcNLs z8-?2L6&qb+Er*=bN$dJtSy_&4+XMz2GS&g4w%tVX&7n<`a+SukEZ?a_B4!WMeD|4BWsjlH& z(=T($!=5vIMtUe*ykd5}UqxzoMgAfrWz zr5x`mvN(WpKuhn-scvjnYtv+U8?P03U6@26`%Xu^&(87AhP#KkyMuw_T)m*MDv(RlNqZ|8vvZ|FKLN%PKg){Yrr3;N zk)g{%b8cml<5LarGW^srGkPR za(+w$a+cmIl0T*NBZKvg(m3&_X2~pB36lPXzg$HC(rE-PVnil;$Eg~@s(bxno_IKO zMUlDr!Afw7PJdzMPBjLWmJg!WHOks3290It zD=V%!O*K@NAzt2_4D98-LApMl*V4xtxW#;$oqo?L;ifr)zc%&&7FDp+^agGbi<1Re zCeq)g!WJ%%f=PqZYx1xIKn`wF$sIf6FC6`?m=z!NEj|QhtOauz*NE`)0mjcb z98e`@;SrP46@!eYVsOz363*A>!33=f?+J@88<}QdZJDw=9dq=Z=0Ro;M>Dy zuhaNm^RT~D2~PGACE*n9eKH~9t015tQEONgQnMK|Dp1I&!aBWIzFD{GOVQUG5+5{K zzKQ}+1(9s4(YFsv@u*C-de4;>zP7udI%sq&OH{XNwy(kzuI=ug*t16O(*#~D^MG4; z+|sBX%QA3i_B=!S$M(6;olZ$on~^0#>?TG2_byW)@~-1GrMNuwwL>ptJ^3}}19xPP z>zqIxcmCRLl=zkRdh&0-4K-Z3Vi#G6=Mxc;A4*JN2oPtux>w*f9s3af&9x>DyjS5g zNO{Nu@_1UpDg@H$iStCmeoU(j6Lt^at&IJ7nE6NOP||AB4A<-lTiwPcMyPOre~ME% zV>uZIsut*;A3;SE-;x8_8G*b3x5%E5X59)AkY>U6F%JdBup#DlH;o%N;jjopEsnYN zK0s!$4yc~(sSrnKl{0?xppUXf0nea?lMS|dxiDWXNdz~xn;fR4-tkt@wZ+VvVRx#R zW}sS3{h$9u=utQxLjq+6Ww~i2m4^`w#v|$v%p~eQGeNs0P_Y;KKmYIapBeBJKmQJZ zild@X>Cb=3z~-NS_v=G;zKtr;-G)_BHHE6k1N_j;eM-%|wh{?oH_A5;aOWb1@U2A-FgO|}*s!>V(Xq?DL z8FHou@kWT}&Fl3k*be7;KWKG#H=ZfulK-8o8Na?hO!Ux|9!!WA>M5(~J&GXZgLU_F zJWsY6Q4p9mQh_Z5FMVeiF{XaSm%&P;32RQL5#!+?v`X&6pVen-I zr3#3Dv(xHW(@U6A`9&TuJPSUJ zEpYF?o>dUvo*mS3U|+p}-*cD8do7!bi2~tR;TJ%F&*3P_AS+zOiV%ZKeBtvXe;@g*`1wLXt5wXRe(!TpzxNR{0@taA;dUw_=V7}9IFX@s;&K@%Wa(){ zIZxn!786OK+NFAuwZi6MMK;~Gx*R4CIbNHLWW$QI%Z79wti_Y<#NV$H$VI(}I9t7h z#NF@cL4?Z5x^YXM99?`ff<*iUO+!Pu4d~zf^b7s*1MgKNv7+ki0+{LE z#DkVCv%3_m*kvMk%7yM%Kzy%E+S*Fht`thJp7ttxd$3meAm`$LvI zBl};AmBE1US73eR50n#MjMfpi&9CU72qpHMFGR8gd*6ue;;E;ck3QGTX$qcS#7{-(diO?=F zfl+c@UjU7baVK=$l1S?!l3=^1gHkS)4;a=p=tx`M3t9v1@A90YX?~y^e_LXwTnCmA zVWw~XBn7A92OoRW7rHsy9JKq0CYl3VqBy(ub5J7V=A7@SKd}8j3d77SnT2u)Y~mG# zabaTZ(;F!%!+rI*W1bILxxBCyi!?%KT)<;pEO*h^Xy2i~LC zJ=&mk7_W+|pB4Z%9ZGvtQLI*I=$<_aJxo^z0oQX&ShhNy5&|(ztYVm$H{>=ar4_q9 z)3Cro_dr_F_zynlQTINO=CrBb=(QWxpajEf)rvwr(}RJ7NN^^a;jgUCY9^O|pqNlk zfD{%62#-LFb__aLsX<6JVsp0E4Sd*b_uqh)d1lmxSny$QR3{az)}7X%WRU8L)gWWq zOWPPJisK|X-2fiiFbAM2Fx&q46=gW81iPUBS(A*3I>Nt$iio7yw&+yH^3!2{K<8(RdcFdgLFHbv1YkQjbuc=9K#Ayn*;KV+=K3vPr51tpGSe@=n@6YepM(8vLx zDgQcHEo2(kf@-uYxRg6K@slatZBgp&R1w$>HNRdJfeox^x0YaGjz#c5F*jncEe4ol z#(VqX-~HL2k7YFBFMuqYO8VjkuZDkg3l$?PVxv%b0wB;k4hm{@W1AH?EE~f4^<*1x zZRbWGTHOqsmtbEDzdjilY;pIH8lBRn+Lp7&qNdQdD26j$k!9>N8SqZW(^S-LZZ|p= zwTqrDznJ4-c6p0Xvr@5IE!J5p1EP2U*Cc#bS90e6{LdayyUssp_lV;KQ^K3Jh7oM2 zQ_<*kD^PkJYZhnhN8SxNN8PXpf|t9=oG>HSHpYtWgT0aA&;=O`a<{I$nf z$32{5&*2qdsQHZOSW5IyH{&TufenX=aA*YqdAniqpfh1C5k~48Hv&}qV}`95=A#_9 zea;~V78q1x;7Wx@J0xaZVurY4S<=sP z$SY6wtdwu|7rZW5VkmDGGa#vW6er%QR!r2|qi=m1z;>ygm>@Yr@#2N%Uo(cLmNZ6p zP^DRAwd6)A-Og);?$I<7D_WZKJbi9)U27qpf|gTw4n`nTdusUf|!eQ z=g#~VT*SJMNM^4x@EX1$Gam|5s2Bc3hbh~vGn+W2Hs=iAfIEX8w1;I_D|#o_fmlyW z-P+(LHL@?m@ZoMvO^)s?^wB9{@SQ>HF$iKQ4Nb|(LN4>Aou3N)lM$t}2;^dNx4%vO zXiG|IoKB~-pCLbT%@e81tZ~OCCH_4nPg)UgnQ1I-0|9ev>L%AB}@`m7%Ilb^i1bJ^|}1IX?3SI)$|K(2rqjaWxN|1MX*sndgOS4`CpJ2tg= zMjmg_F=vnHfth0ZLZ$Q{Xf^E#mH~6zZl`C-vW&bde>Mwr+~r+xfr_c@BqxGAhgLRv zbjR{~8L4O;f75GM=pwhQ^5|B@NcR*Ewu?v?!v4uvg>&mCy1K*z2ssxqrRZj-QliI7 zUxn-fQroI7=A`8=u=aX3>j{RK;#jO9H>|4(Tbprbuo!97TYrLK>cX_KJ?kyl!Bz;c z`=U+Ntx=Y0UR=;I) zS6D+8KV-^Enk|5bf^3>XHABpi^VOVe1|v-h{#`AR=9arc)$a`)1g)laNp>t{VuQiP zOCK53sd)6s*QWDY8ggued$m|h-0R@cNYNfgX{h$2+1_*0{q#E};C2(m>Y&y@r(R-! zB3-REfs6imzJSkF8YK%xBnvWZ*}f1A=RQ^kf9)N2Za# z+71M7qz35g@X!1`DXp82$SDI+3+~md5Pa(OdKQfZ zqb4!{F3-_AP_JJC(*4pSujl4X=#|V}UCKyF(=_L~*=?(NMPIEQ8T}hftp<3*_35zt zRcNS^X5x9kliti@6;kGa4z-S=n6d!pLkVvz08y-vl+&@vIvr3ptzt^7gfsHQaR1W_ zpJgfBoo3@GG?XdUz)KN1_1&)?8gmchb%(=V(LeAI2?k)vsbztM(bTyvf-B#II{L1? z3fPzm#VM--WmxgAWWcR=yA@<6>1(UL@rB)j>%$W9UY%+oHQx26@BE;^zCV{y@bh2a zgAXKFiaMg|!*)f#xMc}nyyc!0Im!E<|LGKLz%mu&3gdI6{HU{2QdaKkJ)~AOilFC%eale z3o*k?9cD2B{M(uh)EZHgkrbRCpVyIE9q0lH?o@^=RVRn!Td1hIwwex_r_nT06On8Y zHqbY0;D)aF#E+(Fp}06_xR&A?5+(f6QLUP2s{*4?bc-ii&>+qr=-bpN*J8>QulG}P zq-^6`0?2EwvAk~dM7>Vly(>U>Wi60pr>b62GH8)^D9)yVzbX(ClMjcy^oC;de3P<( z-V*Po7R@q@a#wT*77ug=DrQ~5F`1_9M~e=a=~e}G^lSOr?#0k9aJuMWdmPgtYI=nj zZ%;f5Y)VB%tz^r=tr(;`Kv?3lkCm8#;WYbq6JyR{ie#oa81z1d$6~F4ER4BW1nc}a zI`(fcVR-Mk6UMMb%E0d`69Q-Z4y0@%N z%EDx5_Jp3EQl$@G`Y}Da)xsvvJeCpD^ z+dAl8>N|h+>gZ#5p7kX3s8g1BXC3@ycu%-7vFGuk(w- zDA5rn`(|`xX!l|-KhR^I(D(J~zp$iYnzMwTdHc9K57a8Ayeoy+P`tN!rV4iE_HfW^ zSK-MtK#ZG}n}$@@p276SBeNv;qI8fw$Nr0f8vA*rf<+PhzsMZaRUECEi7A zK~1)qrOqQ-bQDG{aOB8|47`6Z^)QznYtm5$7=$56+x6VY+0v(Vngxq=Djgji>2cm8 z?+xv-KvGlz_DzeC;yn8KuP3YvmR+$9Jwc~kp-|Lm@4mjD{~NMd>KApg0X^m$?0fI* zQv&a9A3wCoVkuH^G>Y)hoIG`EA7q(3im(}Bwp4hxb;I{XJypRA({7X~{EReO6kWmW z7%vrmIuWZxP`ZxqUxhQY+}8!JM$?%v^c75rSxfqm zCdH!ZTPc=noY%`Vtkq#_R`7N@E4EWDb3O)t1k`qr)pyW0JNEWD3EJ!KLvG#S&VKp+^!3xUQM9R}#{qPF8x zPO~)!{9UaMUb6z{Ypu8|Gl_biOCz08Jg%1O47{NMs7nk~1?5PNk0e z335pwug|h_rd}7dl(L%cZcVq_C{dfrChtFLfggLg^|_k6eDR9qYE8vVM)y9FKIPm=y$Bt1ryt&38vlwzaOG}4T_qX22fGl)F1HEwSBp4E^!rF?pme9>X} z{wd7+Z+pwp*p3PX1bZadgm7B!`B8;|-J#f1J+WCKklGw}DrnY)cPyA?t3W4&R3mS~ z9}7RIGqT*QqA_oEEnYP6WvPdsz+Qd(B;|}3=Vv-if|3razg(8EJANT67EEx@ezz>j zi4%00g21qw9_k~9BHQ~wCh@j@g;uQa5lM2c9+?`$fsSDENkj?Ko51{m18)JuRjB{C zmZlG|pb@+Mr029k4W;#2`2v+)-+-`m_-#lMb#WuVRK|0G#W|2()V*5z-JwO`J_j8ion~(omo-LFvPdIv^pU^0@n_8Pfq$Q&XnHS$ z2zY>^JNJJI4`&P)d%~<>5UgwXA(-i|=QgZirL3TyaR&HMHA9_=zJ6=QRXN+5P61sH zeO-!zuFjIl%`vhsm$d4p+FymVq2qO&Q04@ZG3*wu!LR6LYN?s6kMKXFAzemj7bmxQ za zn`^(M7Y176ttkh?g1J4vvx@vj_$+d|C4$;-{ODmJmYJq@o#J=HJ&n~JbO)u=A#@n9 z3aRE!yQWiby*X9rT^M0n{G1}8kKbg26=s6r<2N3(JlUSys`=R3zE0Crh z&uB+6P2-BX_|li2$I-cMC?}TM&1pE#13n-*hhoLwV%KiL&xB4*T04dXH)qTfp7J>z zQ$i*^1JsnR9TI1g_J}_o`eIzujnVAsvyTIb6MCP_r-H*3IavE!ny~^3{T~EdX~uh%I;Yoxxz_ zw!4t?taZN%(#Hdtt-Cnf9^cacv~Kf7)ywk*ty%}82INa;QmaS!B~Fx)1AC^UEiJ$R zOfCctg7I=(`qVBPMi%2VPt*#Z-H+xg{E=vlA9}HJI}Jn8t}Qe7rKrSMQO<1gLQ&#U z`$D(=n!^F0;QrgLLHjT873kvd+?vG$t6|iDjXyCE9Xb5I7gC->bk{E9ez%r3F`dV` zBvIEoiofk1gR9_Cw?5OxEAu%(P;StwKWMt9=6Kq0<@9X%nXlV3ii@7Z!1t-%)tV z;eV{s?DcjW#O794n$*BgOVU4`S=Vv2pfz?UMzl3gi@%a6VsQIKb_VrZqH&A(!ali| zbm*ujpeB#ZcTVT4fVLSHGB_$G&Gh{*!Mc(#paBhdXgEs(=zAe)*!_c&4hVt!Bn`NT z1_ZDrig8-b4R5yDDy`d`K6tXAhHe=P__$i6dG#%ekpUE8!IMU(Tm4$b5Dg=22oqU- zF)6p>J!2I9xM>SmprlH3F%3i6zuGLzbn^{rig00TcluwZZ!1irZR+O-vzf!Im*mvh{LkZM#KkI)iTe zaHo)c0#YLB4+Z3F$LJ7+6M-| z(9L%NrxaI|#{E%E|6-vdI=9cwq%(2|I9;j7FyB%}S~~gg;8BME1sQ;^0{z|?_zHWI zoQq977z;Ngci|?E=!5bR&qUA-ABII(6lF&M>>Rz55MdcOm)Cu6FmM~Udt)nG-j>;L zasMiF8rqjO3HQ`IrFi-Mn=~dzE;y9oQMD%rOsdh)G;jX({lA`ICSdqiC3TBu0sKG% z)G5*v=}WV(5O;%e&SYF8BoJ^v4daLH6R6_g9obqrtY-K?XVK!w;M%>kavE?{{jP#O3_7g;DnC$JBjXMBkY{FBO7 zzDGVnYN))e%t^AQi(*!FR*VjL68XZbli%GvukUs1#2({$rk=rJ)eC?N3R!~^&h%M5 zvRY_?mZNDP6dtN1w>u8Ac(}f?#4REcGQL^T>+1B^Y4R^TNCbsU&Y`q82jTIF2ffp zSz<;zybI%eUb@`A3MFpbz+MHfL!YtUDUkj6w?*^G&$%}oD;%{1uVK>sSE&J+JJ(E4S|kBbY6~H(>(QQbKp%I zrrv#9EzBy@&ehtqfcA95SWJZu{Xw^LT=dTN$xzr_=;u=zeN49tNH3kMl-}eguQ_>t z;=Yt&-)gD~Wm5D&BbJb0Gxpb$-bV<|YK@BJp%tH@VP@M6gLwYu8E+m}7+vrWRyy1i z5Q>;i`sY>LdWNRP5x_iAcEr|9M*^rkqF)wlkKNnW&v<~Nzp1s){=ygFJXgn~zO$5m z2U%^E>$hgJn;Qe{UrSJo%*-=l2$24H^&X10c&}uVe`14NAfY$v=>FJcLgU| zV5(s@ibM_z3w_h&@BbO4Fh0AaN8uws6|;Q4`p4z51X25so0?GM4d-{S>*&=KMIsRE z?;|8hK?R_5qjFe(UGbQXvupXv7Z%2GHB8%T8cbz9WBBp*{d^8NHH5FBuK{wgkROGaCSx(=4CO(p_WPTOyC+a3=W-gb}JdA}IePBEt8B!}dY0qQh-&yJWzYQSo%5Fwf2|?Q4 z?+Q3bH|ug_c>#$wUeQ0g(;1!UqUI?wj)x~Y`u?9OdA!PH%84IbLez14oU-NJTPj(O zDecX{)e#Kl0S%SjzzlTgUR`l6H>JO&3ZKqH(Q}%VzFj$xK#fyOg{H@x0fB$ z9+73KbxU?L_uPC68jZ%MMsxWKs8cq&MkX z$RDmc13HWsd21bS>7)Do|DesFHyIpvy21fDoc@P#u(uP4-Q|9)56PcXrs=!kI{neW zTBUpzV3mB9sk&fVZUaFwQ_|Ktg`JAv?JTN-McO}IsC6n{9Va_!!#%%_L(M~oleb%w7hW4L88(qw46^w?U`b9<#e9EIat z0)viK!=Iw^qgp5{_c{=2Y}gRh?;(JKOUm0kK4-kUn@P~NA)(-lYKJ-U>$)>=Fby}+ z+GRh*vv?s1`Sxox1q_%OYZc*NMd1O7WA9{t`aU-7yLLyOB4Mu`U8uyW-WS%piYx6Rbo%S#4(sM_gS2Q${lIHsbYgq zc6-!0%)(AH$fnXW^NF%3g?)#Tag)DLR!hiJ$@+@F$crQ;N1n1nE+5?Mw za2CV8NJRy`(&9b`%S5K)Nt}l2spi!ElbaC&aL>m(lfzNnomwvSVhe?$`nmnPB-iX^ zjinj+l!h9XI|hRKIB*q#3A)I@u&TGmLc*Rj{Squ2foL zV!uZ6Czu|cwl3mGi<^PG;k~kZ**ek>TC=~Z*OA*$74^HlVQuvuN!YivyK?2RKlm-h zN)LcL-JdG6K3?)gubRh`UQCak+Xb;Vnb=N2t@Gdsh+6i{qnsR54)lGQkanBIvrw)G|`yiuAlz7RtyfR zWf*4PSL6`y>7~nEe;fqID2{yQ@@_cPa8RRY?|*vl8Q0U9&_ z+Iy3BjSOF*P<|6I6Bh`%|Ni&?DD{!M0_VFr#?Zwnk@!oE0uDWDNiP*t9PtmI2nPht)`&0iXX)fbsPjR6R z`4nT9Z#hhL%_r{Ymebmd5z!@=O`hcSk^pjCvDvYcIcT zLNUP*)oD#t`L-3Qr(g-|4-Ck64qGJ}HjsBHw#6W6ZFIZ|Sv7_Kx!OxM6RN3T^jHE| z=-#0Hx|UN9Gmx~E7?}#n?B+m#tT!<@fGCFfmhfMq94VJz=$Fz}{9K^uH}nVj=s9X> z(^Nl#!c3uDFJXkilAUZ(;stxx11LvvMMbed*MM6QAUERJ_~krGz08w%Lpv`Nu=u-% z(ug*HnJq>1L1daXz*TE5PAsppQg|`A}Bh z_=ZpS2_ZIEOV`Nj6r3jZ2KvI84)O>6T2Ma(;jZet){b~DdkOoLr=lfSXx*5)_dt96 zQ=qfo()nuV1Twa}6y*zczeTHt(#Ewhd}`@&0Z>lCh;GYWin}=g72h z<3Y>d*` z7@X>Pj!`e&4NLHryG4b)O$)ZHC7}7A?Sl;mO)g=bI`IMkD+DTXp|*cS=z4Kq$3dr| z*0gbsGN!&pf7)0=Vfi4-q~on(aPT~fGZu(5NG%yv`?Os+qK4*wjbgxl-6M->%QDipDx*{49%}ut;*qw9L&3sRssI5vqZ^O9!zRriOMm^9>R?3S>;(^c zRu}cOK)iBr+>QgNLqFqL;jZZsH|C`8S72galQ+gOx;qRbYZQ4+`H`l7fuTiZ*pE8^ z%MUqVR}U)6tog=;$``=moraqg9nfAZlaYHMIULnkK+@*Eef3@l-r3#T9a>g|g+Ba_ zI9$^$ztik$sBLs|EL&stdcz~c=5B)6 z*3hOso$Y~@9t1ctGuAwehh65uf*mNA*u)c~l~gCli!TepkBZ7BFp9gxc)w$dC0;Em&8j+P zZ-aD&jQb)^s9*){G-BNbpUtu}ZeSMKqIahNCm&DMx46Vn2{7I0weu$NHR9g>`{=>g zWq>Ygl;fv`g+_rqdw10)1FM4XEHO zO=dwzJ{4Bax+1x0G8vzpdgzAX4*TPYLrt*Yh-$sCmf^p}5fc0c+_0DORo^`o>2@pl zW*DBdrhm_aB+=m?^%1Y*XI(fqFpsbJ(wE4`fi*w}`u~WRL!Lo^VQ1%XTi7|^H2?Y+ zZa{whi}*XpYmfL$GHwn{@*u@8cmWICitcr8c9-c!Csf?WtuNwZgjwb^z{JWpmUGmf z;^^4y{?%^k+hi-Mi(d9G<0e158i7F8V8x9*?4nNTViYQpJpC@9LAM0Xs6Djkti0M) zD;Mbr%rO_Vtmle}8;qQ&hmyX!Zg|x3;pLlrzbex~axYxifW~-5P9_p+vvLoI) zvGe#y3XR>Jxxr}MshR1SBEoDZKcuB7Mxll;xXcOn!H!y;NvHk7AVm($S^19pH3AtW ztj5&79BHRVOMH_AA-Vuly~#8*%UB)mr8O2fD(7+pFG8NVX-IA)Y z`;k={<>`vuyNqaV>oSRS>QT~hw9fRpU9)6(!#F-uvq~nwrfyscdpm35o!v)&4Fml= z3D!_ccwI$#ytP|AZ*05R(_{55J1&yH(=^ryFb69VvM%ocZH!G_Z8B^l~VL#0f04s%Ed!&S=&Q6OH_^{#~UieLAqtIZ1tO+ za(VetsBkA^aTw%Z^8in&kuSW`exl_Zj9Xi!Z+W2J>8F_L4a1xe0n;~Ve+E&I&Y`3( z&*FQ^>L^hb8Dcy0kEMDCcs`(%T>Er)5n+R6K*y;-d{#nS#SWmfI*2P&n-$BAy$iJ; z)`P=o@IJO4&VpmWIT5W~+WWE`T?Ra|H=3dofN$~hLW;3=2>&sJi6RL4Di6J!ksF@O zo~0G}F$mW>DznbdNy&qMb*wyMal47PE$4vW|2N8U*w|i{!i8{4U-@VhAUwAYgT*L&ABiq%yr%fi6)3@6j2EZPy$VBRF!X0hKG(K? zUj*OZcVhExj{DQ_A<UlsENDOCAX zlu1pyuLV=@JbbxW>SqkYyyYP|vIdBeHC}vcQot`7ttG7M0>_Q-zQv3L5ICLW=!>ks z$I|8?-Ds`Bs9%F87LG!>II9>)1%}g}mJ6gEd_X9O!=0qIYczd7>17fZ4Byvs3urf} z^G+shYY;}Isu`PNTYDo_HkcFj4d6oU&&V^pE94p81#I;BlOK4{J^bp~*qJGEp^W>~ zFbq0sJ#kTO6TRmjb@W@nM(pcN=EdFZHQ;G_?T!VaKG3&)7c7^w;-2XoE3}-Lj}v4m z%&nt^ae40aClg`2*<6n^#zLCp-_y;TgKfw!4v+1(dBXF6l0_pX3{P-7F1Ut>BtFbZN!h^=2a7l913?VTx~Nf1ZmMzBr>ZOfQq*H{IGpDe&QEYv3O2Mv z3z*dHN=Nj9NS(BbwC5Me8}@r#5;A;OFQZUVMk9iMRF+ecu_$0HCofNz$GvnXZrygz zNx5TdfCnRh8{3jkFKrn{k+}OV!%00z+?ZM!ZRb<&1B8l_cqUdIs_Q?;;xv+q49$l) zPoV5>2&q%LRys>1Q-;ik0;naP2O7EPl%5E5&H0nm`fs`<4qMfF6n`tw7DWZ;)8ZMr zwb?oq?gKa1p)Nw#TJgUPLe8*6BllW+QD2&P554x-+H3a6);B6kzeaoCI8W3=mSQq{ zr&$%=s7Z-Qk98oGzeW$y8GWoOKQK}CNnXb8&8XY9xoor*i85cjltK(V+dvFZZ}YTd zdhgp}?B|aGcad4ABv3oVhR4IxZXl|4+O_~BaM{v#PXWF9ju35*#hugGg@j;^*Rzmi z8+I^~Paq)Be`>9XuQeLUlGXa;Ee(}Jw>KEKYus*jEy`eKZj6e#Y9tzudcBcETO_rLJB>~3&?>UL~&yD@pZ1@kxl8Zp%QE1aK1iX6q^eij?)jaKJ z=}T7*-i<7}1}ZmN0y zG?Iv5rsd_+IpeU^6aVX6hZ!V0PJg3}Shhv_D>;5x#!~6Am{G_%XLC4tPa~fL6St&c zmTGZ}*=K;#G_|wOF5OiWotIOb>nMc&04ewq>2QM}Z>McbfShSqKFWi?ZwR+zzVz9? zn&sdw6UjPT(|ofD88wPgo4oP}dNFqz@+Jibfu&rgIz!b!wTI~_+$Q>=X#0P`;%^B} zK0_H}11FjG?j^3o*X)M;SUWU(32>Kbx>AoCJ~K8>>RPR5FM!_|Gzcb5+BLn8CEuvh zE27fwxS{G%MXb11==iz1kXLjFnKA!AyPdd4o4tX(D_Xh30p1He(?X3%M3&k=nPT_w zh5tsM1X_FpD`y4~0;o0bd}z?HLyiVD=6`#QkTOMQ5v9FBxlzG0*=Q0k+02sNhp=NT z@PsIl6)n9*MfZX^S1hEl0X~2SW~`VHiB3=OTaC8zc|N1-;;QvB44JJ9kh)I1ro*^; zH|Am3X-pIgJN8n3;Z=Fg2*+(GZI7Ptm_G*(S!p4CyNVe(3?+5QxteZ=k&YJyk0}-8 zz-eGp%Oh)ZIK{@uZ6sgyT}0~^+_oyvDOK^vlKy4465kjx7Mm{djaP>$;644H5QiHy z-?fAmrh#5QK=TJGVhP~YHkU&uzr{<47cAoN;h#6<;kS0T-Z~bqZknC6J_VGHhbldE zJgYUR8WEDgver8mKDkoK+UM}#_jUtS`&J+PbDmHcgbW#e=1@(7 z#mp~gszI3X{RwBI+4}|R!tSv|9?LnA zdcjQG-H*Nn(ZB+cd?YNfFwn9w>6Lu)JiFQZDPC>&leP+Wq?l|W_w*SWjvCOf783rM z^4?>9d|!R>0Ht7l*G^9QQsedxIs%pyD-Bp za)Z8DTsEM#(%jjXpxR2As@7@!H1u30akzz5icX#bFMYY8yH7D^rQ^#_phcw%7-(o6eFTqT_nkg`EZ=WHqOc)sOfhX2B?w1N1)| zFRTZmaW`H20~<=SXO6Jirs8PA7}Amrg^&L#Sn`7^APZ}OhAK!GjBh(-5Fty07GJ)A zG$^8ONZWgdPv<9TAT!3~mk1rnJH5^;i#AE10s0}Zp_c$CqHtTD4~8|~yTNiN z0t7NRm?Ep~`qhM-5QV!#(zh{Iq0$RV^jkU1#{=CG8{|-}H~MR&C|mymyea(lFo>a*; zb}PDOrhQF&>numlgtrNxwdIQ>qa-W!GOFXK+`Y<9?XJrm9PS0ji3l@75A;o`sfKUc znxG*&cyrurKJG*7MObdFoqmny_%9nq-@>FGJDhM;7)J&RA+*5jk^L{HrPkp5(d~9d zgQ}##zD?K}Fix;f;7Z1n$MqrKsQHphPhVd+4$IvdJKQwhhVe7*C>OF;H#(g2Y~(u5 zgH(Z^?$h_`H6R@=Z`5OjRs31HxS}+}{2w2|$FUm^2N7|b)y+h| zd4Vo1=z{lb{K2{#RA|Q)w>E`rrnHyyB${woZU?Tz^&y=R%2u_cj;wTc1zsX-!dSLF zP3eCk&>4BEMSB`*?K*h;8DN)q?8H6|l$piSPl~(OV3ao+4BV0${K3=aOHWEVMrehv2Iu+%>UaT`B;3ctgn3q5o zg0C<3O?6n_F**hqT-v;-rgA0Y|N9`(&>P?xrARYECH3!luxOnIIC$h;DM8_LAIRQ( z29+bG3|s_&QK9eoB~CVa5o^W(L+6*aM>hDOzml$k4Sn@5^Ar5HM&Oe@u}>3v7-q^x zx{vRn`v%(>`7a_PD?oC(F`_ky4s)~=z-b|1Af3_ouW@K0Rgmh-#hYun+ZNb z96?&xW!cxjM=P(_fH4{mJLB4vif-pMlu`b`Z{;1G5~3A4!!dZmX6yWAkUn}zHw+sT zQ`(anJmja}|KEiF4sNaOPyU&nIWKVL#61WAXleW>fm*#chVitg0DB?R8#=M5C^xn} zTrvQ2SAw|cTViD;S`Ty@T&_*?mr`1lhtM^;(%deQ=OdZpV_c~9>)^G<4PDLlz@Z%Y z*IYf~A)x&=s~CAKmOK)qvh&x1U-xSe)9vu$UQ#gb6m&f9**S4aPaXu0^7SU$wl4Vp ztfrK_=m^>8y)1o}7c)!?uMCs|a!X3qtp2I~J90-PoSO}uYgrb5t91su(~!+BJ<99) zsc%CJC6-6H5NrY0xHj|UHo}11=hDXRC#l!(^{N!S0^+>$GmB?omB;X6h-Z$Xi6uDv z11}ZHt}gO?ODUx0h>wmTO$zyBxYkUDBT z&^Zgo)k;c#%BUokA3kB#FYfXSmM~C<{p~;3-sEVU_`2@ELf}$u0bTz|^|rnTKPLMLRdcS4e$r86>TebGZ}A*`6SgLvR5GAx=r*(p{Hp^*YSL=(!HZG zkKl5=XDwX(t(4A83ax19EEAByOBLSTh4J$}s@DK5s{PO3BZl&&1-PsuW0SLqG!xQWEap^<39XL3b3xKCEvYw-8% zj)v`SO--E_Eu+_TVjjA2fTNzp`ud>ej~Kr{s%PWV2YB{0#!A0T=W3((I`m1fROV>` zAE_!NwccV6J4+~o7Qu6m{k7-jgLT?HE0NpjU!5t;#|n*Y#Wbo!)8WU~kca)!d$F}N zKANmCDT>5~J|iX^L6+AbIN#CltO68nE0wK%%yV!t?ksE2@dr^q0Xsc9HeZatMxWwV zXZM2_PrU~2fo6Zwsj=nS;{eI?aa?^qs7eTRyzm|QZ~$(|QO8&`9huu^b(84 zqET(2&2gvczA+i$zI*qPI2cJuuOdBP#F-QuYcx->VIEq0h?|ZLkdz>f#J>2)5A=t; zAWa?jqMNiW$OjY+G%#(yaQXQ*hUku&FftKmJ)=inp0~Go7((@;zziLVT^N0yyOqXa zGiQZM*5v$IIwPNG`%d7fRJeo)EVp3y(B2XY!R;0nwlbHEb;j)(S+@*Q%C@0tp`6o_ zdZ3WD=%Q>gT}~En^Mqp575T(MX#?F(cBiCcb73>E<$mn6y6#VQMkA=UV;@8aZohj5 z)E#x32vWB>K!orwE;ziRpG${wy4neq6nCNQ5PqJ|^G(e*_baHN!D2z>$`<@nQBq^# z$Y#ZzDX%jWm*bYD=!_1Lbpm+#Vss8?Xfz$L6>1%PXg`4~1Y=@!);i-|-e=y0u-r}A zQO&vk8T`#4F7zYc6i-lflQXY8X5Kw|2w-hn;S7Q|UaX+)lq^ogrD;1f|g`S5<8dz5-C};rXY=`~7qf)8}l3A?2SHY>yW~$`>cqy!R z-O%j*XxJH9jG7>#)wcxx4G0$uR85DoR1Vtfhi)G#2q&<5a9EK*T~|&R zy#VfRhmkZyMl)oUolEoqxzmp#+=i7b*&l@+p<=3D5+A#WfwNi}jjfILBF_#rKK4X# zd9SriI)aCXV9&o>?A<()|FL_E?RLACgQ@`B=oTgc=506}k~~Q5Lpy`?LApAE;!oz~ zyul$jS8TsGv{yt(d%oN~BPIoR`eb#7(u!HeXPdIza_1! zX9|&4-@^7b75X=Y^!Zs+YC!M7Gp^v^M4fN{oPm$Kwb7*_duVAUB?%g}cXUfctX_~g z%V3TN3*Ak6#wbTNO0k890N;O`#f`~aTAUJ+TYHHoHTB+X<4jna{rA8B$DUW?USx>~ zf*v80TTwOO({yVvzZ_Wb;){d|kw{8@;6G&Z!YL1dt>*`saZ-8@ZLLaT@S^+GpaF9{ zvaI&rM`k`2EF($i!Tb}>#o>HMF)nQ#M6AwACG+1$@UqzA zjQ^RbtEf<>BV|R09_U63;Jq?PHB@;a3MHJlS8C;R>9JL`)#9YkmB_RsjB`5X2!g*NCt3~Q#zTp#(bIsewRL@Oj%W!pwjK4Y!Gg>-U(Ev z0jkv<+nr-$c>gwM%@{+X-xmeilYX|I!B2tCeoN;o=)yrMC4r`|x8)Cq*d)S!cvM~l zlv~Vz3(M)8VUZm5ysgM*e|c7cv<)8r`7u1ddxK1Q*CzQr1?UFwpfF$COqc9|0cb#D zCO$Uf{KgG#ur*fm1<*Rn3IgXJP|Psl;sfGZ(`m*yNAndWA!`-+*}^vX%2U>7n^YBA zbw>9cY>Bft&Fq%tMVv1~s0N=>d<#XJgAX@$)F4PWY+EKK*SVPwPuH;4*lA_6kaZA= zCd6MMdd4j!E?pzq9V)gsv{^5be8Zhr1!<8m2_AhKac#HT$)InEPmB2WzO6}-Lx?x+ zYpG)O-O|&Yaj##cv)=~5iBew3$k2%MZMhlKCkKR2512w|5ZSEuu`mQhejdmCqUy3LVhVz*wY{dvNDOEjFqx9L6;~(=%!!95D>Gv zp@z0zI0*q#P3f@xph6UHTM~o!D*1nQjSmJ5qEiEl2eH?WYkf(yO7lIKa!*A51c-kQ zaZJklq?gA)_Hc3hR;4StgL?70QgIx3gKoSv;0E&K{esFrZjjxvFn&EVTW49>yD3~q zy>#Onc(`rbQ1@0Zmt5ao;T#Hc%0rTP{^*c-3Vu_R6Gnc@+)X*%XmD7b+-xB#z6@kW z`u@A-o`VKGJJ7z9Ah^vrhy6{ESvYTz;#yG*uyM2*vAS#BB9FG<5Gy!#Mga z_7U70H_$}dgBl(lo1JH+IOS<;%}=C8toUKNrLt*#)S9Vuhg-|I6Y}?5g=`7cy2{~p ztgxJTxV56X$F{M_qg3Pw1j}SLA66Lj`P^_-ZmH33zs4be&6s$7Who_Q@h0R6J>_J- zzJLFw^=qJ?#ZBP>LuPvoE<&{i;4xCIvlAaZQHsqyp&Qj$8yFYD))a8wj6N$AZS)YU zM>;(4F;OaM+ynkcrvfFzpyNE$F{P3|L%flj>DjL}Z@X7X2~0ieB8cisq`Q+7C?KR8sNE@`frl8oUigmfz5|0#6u>RaPDfBG%R2hiTQGPM6P| z5{dMiGAW1;O$dRxYtK=~GW(;9O{E4R!Yp+dzCgzhhBjZL1E!(*XdpTwyq-LjVa2{3 z%ncO+#xJKOc0QXx&#TR&xtSqvaVkP;=qi5nIahFL?qhG%w(WD$9jIrH)X2cHO9aX+ z7O^a=9MC#saucizTJIFPzFyQgMAks`uCZz_xw^tJjyD@^&0B9{(fK>mDodmx`cLz? z-Q242WMZ|~d4AN`CnxJ!o4X zp8G%HY&!rRI;Ly>TKWjdy^##9bXaH_ML-xo2)esyVHqO`Mo4bgkxB!*v2jM*WD-AQ zpv_Retc{(kdLyu|bFaI(caj8p-jlh{!vq-bTAk@rK*cik>*2s!4^-yK}n^U)UOu+r#<@Qq7;dHldzM~x4}cUJ%+oJ zY!y^3Ju(C35Ls(IoU35L?fk2l^;bTCJY&AZg7iVh^_#L#DhMoE{62`Em1}^Hd7vu( zf^uRAyNbGYF^$^wM)scc7q>~^I2(qa7P{x_Uzl5a%vywB|MJFIJUr}e-}Ie$0p#pm zOj*#A4ztP(cVSqyz9I@7pA#l)JNKcT$37i=C(Ap`dN!w#uzC*oNSI|5*bZ!_=(c!) z=_U=;T>JolC)=bEuRfAj`jj#Qw?1oU z(CKz-n4}yNx=xV>;F0R8xCR%&qgmeaM72`B(hw%`N=y)xdl#HoDk`Nm5_&-&!NiJ| zz|qLCyi(u-r_W5i8|JwnslQPft^Q}P_SU3HN@Y-Eu2Mnme^~{|;yZ)(o0dkW0*$0i z@Hcd_D9$(ZEa|A&JMf)jPNTq`*$1i1{;!uo6jcg_`vfW|=r&O4)B2pE67eBUG{7Uf z{r1R0S5O@sFgo*rH4N8lFoINg1X?~&wEVTWXF{lowv5vxOG}^`daz*{@AH1XtUwx~ z3s*~-V=_lJ1&T-5{ZM>pU4Zn1nu|^f`b$Sf4Z^$^z-nslk`fy6&`j)a`WKhU3C8V|mQeszP8m+`0xGbVrsM9hs=_*eXwTD%4nt4e1Y&g)CVCyKg-4HzG%Hqap?Iek-8)A^v9M0&8?n40%dXF z`uR3DYKH4g5~M}{?|c;pb3766^jbPS=;~eNYaR!wWUkV+d{m0AFx^u! zKCs(wx2$MwmfQVq<%u+$S2JteAX(F?({!4l zV_POcNcAXLz1;bR?oHQEpMrTFR%>HA_XEiQL>i;i$neZqIMxOkrkYP(WSI6Om`tum zU($}VuG#iK#oM`{EK)CRNQBdXzUd#fTTkanoTdmcFmANhAn|i6#Q=!jLK{likJ#r8 zo9=!NfiCib)%4p<=6i?RON_nZjH>K?5TZS2;y%a{7Q&&AHi zkeNil+(0U5w)R409oCQyzZh3D)7i=gJuqx=Se*AZ??zCS-%IDW$5l=_eOnd zUU!-<_dGD(wDwG;KgCpw?flej&Xx3m8ANgA13f}WPAbcAVu6w`*FkvHdQ!_R+xk7} z@b~{plmG935A7{3PgAXO5pO*Hpyma;uZVJ$|0zxj0&$mQU0acqDw(nzm)%(CpmiF; zQ_w~5HM$DN9_aGnJi$bN`5Z4X=>QQ{SgzPPP1lN*s4TBelOc!6cc?oRk{G#MW* zfUA?6llLcY1e|oM(iFc@oXABN!3+rTTF`7Z8W?J+73;9N(@1X@#~-)cWE9(M>F-`H z*09*QnSa#LZ-Ec&j4#A3X=IHqSjH3mn}{wkVbelw=jZUvKBHZ*-%`)mt(*tZ@erXX{u_yxe-2`E=Nyx6gDrv)J(wkGrPj1~ zqIn!vt#CIC9KBatFMu>6Y1lZduH&x(iw4#@W&&ONLHkXL`5oPji%AVcrrdP_Py>UF z?%{v``+pRZ!9mv;Vt2(5n}@LDmmb0*GI!YhNvE!BG0meC2!?&xy?q2HToUFij+H>i-}(`2`GBI;GZV>e7_S_r$Q2cGXzvImV$xbYSp*gYToA*Wc7_ZG#M<7U|o`WTT_C~ZH$^ReDnLE1_KR>65*#jT}!f;W6L zr-fnj&tcESeVBNyyv$oERCimj&KE{Xxf;mD$6zC!%Da5A^-9Y5jol4E%gz#9m0vc7 zymglFlsbLIv$N6vr{2RL{h=(<8uM=2|6^k~6KXGvN~fXGH0}+ZjiwJO1@HNT+8W7} zo9`u=LQypU4Xt|kOP)Sb3ZPs6vd43KW4x{nQ$Y(^{O0# z9q?2#Dfn%dz7@q-cdC)A>xDtVV87ONkmd741&=T{i_}VFEL%5u7|*?gizsU6QPQp{ zim&$vg0V5IFP0ROAOho7_Kr?W^^~WrQ^q4#T#{d9!j71oT?h+h_KxaNkSIP)kI=S}g?go67z}dRhZq2b za-?~+$3c4=!XAKKSUHD{_q6rir@LEBhn@b7vu{qF;%B_cwso&L?sT4O<9~PoY{xBy z9HP?TUyb2uC58lY`Cp1A1u_}~$U6U{Ri z)j-nLS`{DwBv14QRoK$|n9c*zQU;A4&_Q*UQNig$)8lxYceHC$X7)zp41lC+H&{l1 zMF2us*yR{yFM3hox+wS^28Y4X3iUzxNS|XCn^rt@sCJ9+>C}Q7*>v7Z`JAuK*{JUK zY}Wl5t?CCG{Bh7Lr~n-`I*lJ<9b$`I`+Q1>$36S$TCqk9Y)5}NqUSMKQ~F9}&Y~hG z@`&6~^!9K^H(o+(6ovCB>Wq4}m!241#HI!&X461lEh`c7=EC`qG}lpfV5 zn4%Wn^4YH`On0eYLy~{1#AU1m{_G^cYAy9HDMgq8II`cvVGycSrqb$`uDbf>Q}s76 zPQni^AiIF?jp%X5+gvQ60&wvN_)#>8c+!EdGPOqjCi64(ckg;&z z-{rwV7x@I|pvL#1B1{6{ly^IfLvpX1zJ)+J4Mnm0UU62$M-yf!Z_FuGe}1m$@#HN( z+!(XPueYuD3BZguaikdbRqCsPicdu`gQvuo_lFdfJ)ICUehzSn zn*YRFFQ{UnBcT=(_Ku5301JIEA$$q${_m)Ur$48ysC7M+bZ0 z#&f_BPcxj-_D3Oq?nwUn{&j`wPkNKO6W~dW&Gr|NBq%pW3RadWr+6%~6$aVU{gd|; zK&K#G9c)>m(x0hE+J{UDu=hvlic;=EE$~D7W@a8lu0f5q_jIljJx{30*sE}E<8V8N zJdTzZ-Kij&vFmGLF8O`xi3VHkp``8&fxIl0@}qttvizGk@y7BjZz_q|K*dN;ey=GNi`ngG6Mi6c10y*6evD} z_lexQV*N@*5nRr{OO4)fkEDi^t~Jo^4F|26USxnkrFAF4SsXIU!No7nKtS6=6tySFy#j4kD?LUv4xrvFWzYCNVg z@lIRskKey<{n#1zJ3qbf5Q#%^#%bdyPa&qpfc#LbpI+3Vd^Bn@fa{HGoJ1vgqJ4NS zCuKqAs|9IMj*u;B@4?*iLPWzv0NfYd&A~Nj(sip<^?8gk`hAM~_+3Tmy6%D1sAmOw z3#rItR-gBsh9YyotssTDsK-Wxw{~N=yWMI-DmR{GP*94acwGoE(fM3@1TeB3?c5Bb zJ{4^Xxw-B*TXE+-581y|lFL0JjPx)372i2_cc;2tZR?i04LjObNy%QLyL&pPlXG2} z9?G^RfIc6|iHX}{zDLK!;Q|@7+pW9b|1(Q<*aCdbd<{YA;=dh~ByO$DL8_6bT3js%%2k(iWTH z2#C21;(4(kG!DyC=;v@VAr&C^Ub}gUJ`s|a59&$%4BFBjHp2*|oyV}^JLX0ST9fNp zya*Jeuhxo+*b-ZDqu?)bn$W8^ROKu!`uoQSs0tZb!X7F#q_2PSImK;i;9AsW&PI@x z=WwR`bGh7ZGGLw3Irxc@Mmzp|1%7gp3OZr$g_uYey)B+ELA6lV;F<4ru#}Wf#$%T! zt=kj0SWv?9WAQy{DZvCg)$+5Sya*l*lCG11)uCk20Vlb%x!{~HF6-AI4KmwnfZK_w zQMp@E>hCk^LoCkcdR(2CT|?pcJrOGzwIA@zRfC)U*oxzSAi{M+{^!Wur}(Rkc-&U8Ko8H$q> zt--5YEG}r<6SM<&td4}<6+ufn675E#xzYONWO{OHS+v6TEG^r&({1?VX_bNS-ShuR zznWjYg<=DjmWl=LK}V^t2gF{bGwyU{GF-l5pD=1#^LMmcl$gJ%aVZ~nJI9?#w^K2Nya}-D(y6jdyfCr|#Qj)Q>)^|X z&HePkB)*J{*3URQ??G+?2%Wba?09tQ;um6GkY@hN4k8}|IbH)1@MaN$X`U3!;5sb$ zYdvpc_b9C*fQVYv7QM=(FswPm*31%Y+abOsgzRovEl;>F-X>X2H(6 zX!`d&ND{!r*>TNoah&tk%54KLuc`*DU2im~$tZ-zo{G&@Mz!n_t8^yLY3k9P;kX(Mwk+$^YrRTl`Fzz9?>LG$c$8Z2iYl8F^bR-Np)tZ3?#U;*ju{Nyz@ zYrudHYr3QiP>)z*Bg!aiiL%Odozi6lOI9E^`H$6&&heW|2J}#_mp%i2=5r11zTc#V zE4^2nr0MrduP1{BWyz7%qN1HU%1mW@I?sY9R02>Na{y=P)ljEM0^^JAL%14s8_-e1 zcCU6#l&ZOP@|-UQ+{D{AEZ3S38$Oz81M=*+HGNj%M`@S}1zDs2%IaPmubYC@9(1g! z*E4!Q3PhRKmfe@a2Qe8Js0Q~hvwi;CGDsk5NICOj+nQBAl{2lduUylE>cdK+K%2a~ zIt|`kVh6OOvM`o*C8k$u1*t_DkWMwwetg|^reD}vVb1WnwqFg$z@%EFKrAQ~$(2wF zT~^%FKij(`{AoihCS}ax?-f-l&&rk*%X#d0#C-}OZ@Al7=HqVFJSRew5Z{o9GKo?G4Cad%AjVj`fk*QjYbZe(YtKOEYhCf|ZDF(ojMy1tn$ zn;_r~J~K7Eb%T4K1*N*B3V-jeCJ4eSL~^9c&)Dy#_jodwc5%P~pv5|npK@OOrN z3qAbTTbwSGbB-^+|ODtlI5ywam@v zA)~uyyn(C)DENlTu<$@=-FpPa@9vg;9@n76AJjSsx2-~cvUQ&1X{>kV<9`EqK!vjL zJutSvm9B!#bE4;uFnZ+W4leUEhI;G_X(W7(TI>hW%4sRD)Qm|X0gqp-soniHWd{zd zdfyHwU!{Ah+n&@pu*7Mgi~i-Bo!<^%l4olE_zZ#3Rw3XnLrJSSbqUu$ks9d~&blk}`S1}s8t`dWh2$5Un7m#g2 CACHE; + + /** Search the offline catalog. lang: ""=all or an ISO code; q empty => popularity order. */ + public static void search(Context ctx, String q, String lang, int limit, BooksClient.ArrayCb cb) { + final Context app = ctx.getApplicationContext(); + AppExecutors.get().io().execute(() -> { + try { + List all = ensureLoaded(app); + String term = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); + String lc = lang == null ? "" : lang.trim(); + JSONArray out = new JSONArray(); + int n = 0; + for (JSONObject b : all) { + if (!lc.isEmpty() && !lc.equalsIgnoreCase(b.optString("language"))) continue; + if (!term.isEmpty()) { + String hay = (b.optString("title") + " " + b.optString("author")).toLowerCase(Locale.ROOT); + if (!hay.contains(term)) continue; + } + out.put(b); + if (++n >= Math.max(1, limit)) break; + } + MAIN.post(() -> cb.onOk(out)); + } catch (Exception e) { + MAIN.post(() -> cb.onErr("couldn't read the offline catalog")); + } + }); + } + + /** Distinct languages present in the asset (rows {code, count}), most-stocked first. */ + public static void languages(Context ctx, BooksClient.ArrayCb cb) { + final Context app = ctx.getApplicationContext(); + AppExecutors.get().io().execute(() -> { + try { + List all = ensureLoaded(app); + Map counts = new LinkedHashMap<>(); + for (JSONObject b : all) { + String c = b.optString("language", "").trim(); + if (c.isEmpty()) continue; + counts.merge(c, 1, Integer::sum); + } + List> entries = new ArrayList<>(counts.entrySet()); + entries.sort((a, b) -> b.getValue() - a.getValue()); + JSONArray out = new JSONArray(); + for (Map.Entry e : entries) { + out.put(new JSONObject().put("code", e.getKey()).put("count", e.getValue())); + } + MAIN.post(() -> cb.onOk(out)); + } catch (Exception e) { + MAIN.post(() -> cb.onErr("couldn't read the offline catalog")); + } + }); + } + + private static synchronized List ensureLoaded(Context app) throws Exception { + if (CACHE != null) return CACHE; + List list = new ArrayList<>(); + try (InputStream raw = app.getAssets().open(ASSET); + GZIPInputStream gz = new GZIPInputStream(raw); + BufferedReader r = new BufferedReader(new InputStreamReader(gz, StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + JSONObject j = new JSONObject(line); + // Map the compact asset keys to the live-REST row shape so the UI is source-agnostic. + JSONObject row = new JSONObject(); + row.put("gutenberg_id", j.opt("id")); + row.put("title", j.optString("title", "")); + row.put("author", j.optString("author", "")); + row.put("language", j.optString("lang", "")); + row.put("download_url", j.optString("url", "")); + list.add(row); + } + } + CACHE = list; + return CACHE; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksWishlist.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksWishlist.java new file mode 100644 index 00000000..a64831cd --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksWishlist.java @@ -0,0 +1,83 @@ +/* + * ============================================================================ + * Name : BooksWishlist.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-4853. The persisted "order" of books the user picked in the wizard, before + * the system (and Calibre-Web) exist. Stored in app-private SharedPreferences as a + * JSON array of {id, title, url}, so it survives the system install. After install, + * the provisioning step drains this into BooksDownloadService (which already takes + * id/title/url) and clears it. Kept minimal on purpose: the URL travels with the + * order so provisioning never depends on the server catalog version. + * ============================================================================ + */ +package org.iiab.controller.redesign; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONObject; + +public final class BooksWishlist { + private BooksWishlist() {} + + private static final String PREFS = "k2go_books_wishlist"; + private static final String KEY = "order"; + + private static SharedPreferences prefs(Context ctx) { + return ctx.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + /** The current order as a JSON array of {id, title, url}. Never null. */ + public static JSONArray all(Context ctx) { + try { + String s = prefs(ctx).getString(KEY, "[]"); + return new JSONArray(s == null || s.isEmpty() ? "[]" : s); + } catch (Exception e) { + return new JSONArray(); + } + } + + public static int size(Context ctx) { return all(ctx).length(); } + + public static boolean contains(Context ctx, String id) { + if (id == null) return false; + JSONArray a = all(ctx); + for (int i = 0; i < a.length(); i++) { + JSONObject o = a.optJSONObject(i); + if (o != null && id.equals(o.optString("id"))) return true; + } + return false; + } + + /** Add a book (no-op if already present). */ + public static void add(Context ctx, String id, String title, String url) { + if (id == null || id.isEmpty() || contains(ctx, id)) return; + JSONArray a = all(ctx); + try { + a.put(new JSONObject().put("id", id).put("title", title == null ? "" : title) + .put("url", url == null ? "" : url)); + save(ctx, a); + } catch (Exception ignored) {} + } + + public static void remove(Context ctx, String id) { + if (id == null) return; + JSONArray a = all(ctx); + JSONArray out = new JSONArray(); + for (int i = 0; i < a.length(); i++) { + JSONObject o = a.optJSONObject(i); + if (o != null && !id.equals(o.optString("id"))) out.put(o); + } + save(ctx, out); + } + + public static void clear(Context ctx) { + prefs(ctx).edit().remove(KEY).apply(); + } + + private static void save(Context ctx, JSONArray a) { + prefs(ctx).edit().putString(KEY, a.toString()).apply(); + } +} diff --git a/controller/docs/ADR-4853-wizard-content-preselect.md b/controller/docs/ADR-4853-wizard-content-preselect.md new file mode 100644 index 00000000..7716a2cb --- /dev/null +++ b/controller/docs/ADR-4853-wizard-content-preselect.md @@ -0,0 +1,55 @@ +# ADR — Wizard content pre-selection & post-install provisioning (Books first) + +Status: Accepted, in progress (ADFA-4853). Books is the first module to implement the pattern; ZIM can follow. Depends on the Get More / Books download engine (ADFA-4850) and the live-content channel (ADR-4832 / ADFA-4832). + +## Context + +- "Get More" (ADFA-4850) lets the user add content **after** the system is installed: hub cards gate on a **live endpoint probe** and the Books catalog is queried from the **running dashboard**. This is the post-install door and it works. +- The first-run **wizard** is the other door to the same idea, but nothing is installed yet — there is no live server to probe and no dashboard catalog to query. So a "pick your content, then install" flow cannot use the live path. +- Catalog availability differs by module: the **ZIM** catalog is already app-side (a mirror the app fetches + caches). The **Books** catalog (`catalog.db`) lives inside the dashboard, synced from `iiab.switnet.org/android/pg/catalog.db.gz` (~170 KB gzipped). **Maps** is whole-system, slow, and ships by default. +- Tier reality: **Calibre-Web (books) ships only in the Full tier**; basic/standard never have it. Kiwix/maps come by default. + +## Decision + +Separate **selection** from **execution** with a persisted order ("leave a food order; cook it when the kitchen is ready"): + +1. **Offline catalog asset.** Ship a trimmed, gzipped asset in the app, generated from `catalog.db`: `{id, title, author, language, download_url}`. It powers a minimalist offline search (title/author) with the existing language filter — no server needed. +2. **Availability by source, per door.** The same Books screen has two data sources: **live REST** (Get More, post-install) vs **local asset** (wizard, pre-install). Card/option availability is gated by **live probe** in Get More and by the **tier plan** in the wizard — Books is offered in the wizard only when the chosen tier includes Calibre-Web. +3. **Persisted order.** The wizard selection is stored in app-private storage as a list of `{id, title, download_url}`. It is app data, so it survives the system install. +4. **Post-install provisioning drain.** On first boot with the server alive and Calibre-Web present, the order is drained into the **existing `BooksDownloadService`** (one at a time, per-item retry, continue past failures). No new provisioning engine. + +## Why store `download_url` in the order (not just `id`) + +- `BooksDownloadService` already consumes `{id, title, url}`; feeding the order straight in means **zero new download machinery** and **no dependency on the server's catalog version** at provisioning time (app-side asset vs server `catalog.db` can drift over releases). +- The URL is one short field, and only for the handful of selected books. Keeping it removes an entire failure mode (an `id` the server catalog no longer knows) for negligible cost. The alternative — server resolves `id → url` from its own catalog — couples the two catalog versions and is rejected for v1. + +## Why JSONL (gzip), not raw CSV + +- Titles/authors contain commas, quotes, and accents; a naive CSV split corrupts rows. **JSON Lines** (one object per line, UTF-8) is delimiter-safe, streams line-by-line, and gzips just as well. CSV is acceptable only with strict RFC-4180 quoting; JSONL avoids the trap. +- Size is a non-issue (~170 KB gzipped for the whole catalog), so gzip is for tidiness, not necessity. A trimmed SQLite (+FTS) is the upgrade path if linear search feels slow, but is out of scope for v1. + +## Options considered + +- **A — Wishlist + post-install drain (chosen).** Offline asset + persisted order + drain via `BooksDownloadService`. Minimal new code; reuses the ADFA-4850 engine; matches the wizard's mental model. +- **B — Everything post-install (Get More only).** No wizard content step; the user returns to Get More after install. Simplest, but fails the "pick content during setup" requirement. +- **C — App-side live download at wizard time.** Not possible: Calibre-Web/dashboard don't exist pre-install; there is nowhere to put the books. + +## Consequences / caveats + +- Provisioning must be **idempotent** and **de-duped** against the library (reuse the existing in-library check), and tolerate partial completion (keep the order until resolved or dismissed). +- **Tier guard, both ends:** offer Books in the wizard only for tiers with Calibre-Web; at drain time, if Calibre-Web is absent, drop the Books order rather than hang. +- A stale/unavailable `id` fails only that book (existing per-item retry / continue), never the batch. +- The bundled asset goes **stale between releases**; acceptable because the Gutenberg catalog changes slowly. Fetch-fresh-with-bundled-fallback is a later refinement. +- Trigger the drain off the same **boot-gate / server-alive** signal the home already uses; surface it visibly (e.g. a "Finish adding your books" entry into the download-manager screen) rather than silently. + +## Scope (v1) + +- Asset generator (catalog.db → JSONL.gz) + bundled app asset. +- Offline Books catalog source + persisted order in the app. +- Wizard Books step reusing the Books UI, tier-gated. +- Post-install drain via `BooksDownloadService`. +- Out of scope: real covers, wizard-time catalog fetch, ZIM/maps pre-selection. + +## References + +`BooksDownloadService.java`, `BooksClient.java`, `BooksLandingFragment.java`, `GetMoreHubFragment.java`, `SetupLibraryActivity.java`, `static/dashboard/sockets/books.query.ts`; ADR-4832 (live-content channel). Ticket ADFA-4853 (parent ADFA-1028); builds on ADFA-4850. diff --git a/tools/gen-books-catalog-asset.py b/tools/gen-books-catalog-asset.py new file mode 100644 index 00000000..2c74afea --- /dev/null +++ b/tools/gen-books-catalog-asset.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# ============================================================================ +# gen-books-catalog-asset.py (ADFA-4853) +# Author: AppDevForAll +# +# Generate the app's OFFLINE Books catalog asset from the dashboard catalog.db, +# so the wizard can search/select books before the system (and Calibre-Web) exist. +# +# Reads the dashboard catalog (SQLite FTS 'catalog' table) and writes a gzipped +# JSON Lines file — one compact record per line: +# {"id": , "title": "...", "author": "...", "lang": "en", "url": "..."} +# JSONL (not CSV) so commas/quotes/accents in titles/authors never corrupt a row. +# The whole catalog is tiny (~170 KB gzipped), so this stays a bundled APK asset. +# +# Usage: +# gen-books-catalog-asset.py [CATALOG_DB] [OUT_JSONL_GZ] +# Defaults: +# CATALOG_DB = /library/dashboard/books/catalog.db (on the installed system) +# OUT_JSONL_GZ = /controller/app/src/main/assets/books_catalog.jsonl.gz +# ============================================================================ +import sqlite3 +import json +import gzip +import sys +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = sys.argv[1] if len(sys.argv) > 1 else "/library/dashboard/books/catalog.db" +OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join( + HERE, "..", "controller", "app", "src", "main", "assets", "books_catalog.jsonl.gz") + +if not os.path.exists(SRC): + sys.exit(f"catalog not found: {SRC} (run on a system that has the dashboard catalog, " + f"or pass the path explicitly)") + +db = sqlite3.connect(f"file:{SRC}?mode=ro", uri=True) +try: + # Ordered by popularity so the app's "Popular" view = the first N rows, no re-sort needed. + rows = db.execute( + "SELECT gutenberg_id, title, author, language, download_url " + "FROM catalog ORDER BY downloads DESC" + ).fetchall() +finally: + db.close() + +os.makedirs(os.path.dirname(OUT), exist_ok=True) +n = 0 +with gzip.open(OUT, "wt", encoding="utf-8") as f: + for gid, title, author, lang, url in rows: + if gid is None or not url: + continue + f.write(json.dumps({ + "id": gid, + "title": title or "", + "author": author or "", + "lang": (lang or "").strip(), + "url": url, + }, ensure_ascii=False) + "\n") + n += 1 + +print(f"wrote {n} books -> {OUT}") From 2b110b746db2ecdda873776c050e236c79bb6b6a Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 09:42:22 -0600 Subject: [PATCH 02/29] ADFA-4850: let book covers breathe + avoid adjacent same-color Bumps cover cell margins (6dp to 8dp) so cards don't read as one block, and makes cover-color assignment neighbor-aware: seeded from the title hash for variety, then nudged so a card never shares a color with its left neighbor or the card above it (2-column grid). Color still carries no meaning; this only reduces same-color runs. --- .../redesign/BooksLandingFragment.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java index 8df4d7a3..569d84c6 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java @@ -235,6 +235,7 @@ private void render() { status.setVisibility(empty ? View.VISIBLE : View.GONE); if (empty) status.setText(getString(isLocal() ? R.string.k2go_books_local_none : R.string.k2go_books_none)); + final int[] cidx = coverIndices(); for (int i = 0; i < books.size(); i += 2) { LinearLayout row = new LinearLayout(requireContext()); row.setOrientation(LinearLayout.HORIZONTAL); @@ -242,7 +243,7 @@ private void render() { LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); for (int k = i; k < i + 2 && k < books.size(); k++) { LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); - row.addView(cell(books.get(k)), lp); + row.addView(cell(books.get(k), palette[cidx[k]]), lp); } if (i + 1 >= books.size()) { // pad a lone last cell so it stays half-width View pad = new View(requireContext()); @@ -252,7 +253,27 @@ private void render() { refreshFooter(); } - private View cell(JSONObject b) { + /** Cover colors chosen so a card never repeats its left neighbor's or the one above it (2-col + * grid). Seeded from the title hash for variety, then nudged off any collision — no meaning, + * just fewer same-color blocks. */ + private int[] coverIndices() { + int n = books.size(); + int[] idx = new int[n]; + for (int i = 0; i < n; i++) { + int left = (i % 2 == 1) ? idx[i - 1] : -1; // previous card in the same row + int above = (i >= 2) ? idx[i - 2] : -1; // card directly above (two columns) + int start = Math.abs(books.get(i).optString("title", "").hashCode()) % palette.length; + int chosen = start; + for (int t = 0; t < palette.length; t++) { + int cand = (start + t) % palette.length; + if (cand != left && cand != above) { chosen = cand; break; } + } + idx[i] = chosen; + } + return idx; + } + + private View cell(JSONObject b, int coverColorRes) { boolean local = isLocal(); String title = b.optString("title", ""); String author = b.optString("author", ""); @@ -264,7 +285,7 @@ private View cell(JSONObject b) { box.setOrientation(LinearLayout.VERTICAL); LinearLayout.LayoutParams boxLp = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - boxLp.setMargins(px(6), px(6), px(6), px(6)); + boxLp.setMargins(px(8), px(8), px(8), px(8)); box.setLayoutParams(boxLp); // Colored cover (placeholder; real covers load later from cover_url). @@ -272,7 +293,7 @@ private View cell(JSONObject b) { cover.setOrientation(LinearLayout.VERTICAL); cover.setGravity(Gravity.CENTER); cover.setPadding(px(12), px(14), px(12), px(14)); - int color = ContextCompat.getColor(requireContext(), palette[Math.abs(title.hashCode()) % palette.length]); + int color = ContextCompat.getColor(requireContext(), coverColorRes); GradientDrawable bg = new GradientDrawable(); bg.setColor(color); bg.setCornerRadius(px(10)); From 8ee5c7f511a58b18734fdb8db32b43bb36a1d4dc Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 09:47:48 -0600 Subject: [PATCH 03/29] ADFA-4850: apply the book-cover gutter on the effective LayoutParams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8dp cell margin was set on the cell's own params but discarded when the cell was added to the row with fresh weight params, so cards touched. Move a small gutter (5dp) onto the row LayoutParams actually used, giving an even ~10dp gap between columns and rows — a light separation, less than the Library cards. --- .../org/iiab/controller/redesign/BooksLandingFragment.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java index 569d84c6..04fb57bb 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java @@ -243,6 +243,7 @@ private void render() { LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); for (int k = i; k < i + 2 && k < books.size(); k++) { LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + lp.setMargins(px(5), px(5), px(5), px(5)); // slight gutter so cards don't touch row.addView(cell(books.get(k), palette[cidx[k]]), lp); } if (i + 1 >= books.size()) { // pad a lone last cell so it stays half-width @@ -285,8 +286,7 @@ private View cell(JSONObject b, int coverColorRes) { box.setOrientation(LinearLayout.VERTICAL); LinearLayout.LayoutParams boxLp = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - boxLp.setMargins(px(8), px(8), px(8), px(8)); - box.setLayoutParams(boxLp); + box.setLayoutParams(boxLp); // the inter-card gutter is set on the row's LayoutParams (render) // Colored cover (placeholder; real covers load later from cover_url). LinearLayout cover = new LinearLayout(requireContext()); From 424aa7c1a253197d7ea78cfa145b2edba39ecc7e Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 10:03:16 -0600 Subject: [PATCH 04/29] ADFA-4853: Books screen in wizard mode (offline catalog -> wishlist) Reuses the Get More Books screen for pre-install selection. In wizard mode the source is the bundled offline catalog (BooksCatalogAsset), there is no live server, and Add writes the picks to BooksWishlist (drained after install) instead of the download service; 'in your books' becomes 'Added'. Educational/My books chips and the downloads link are hidden offline. SetupLibraryActivity.openBooksWizard() launches it. Not yet wired into the wizard flow (content step + install queue come next). --- .../redesign/BooksLandingFragment.java | 43 ++++++++++++++++--- .../redesign/SetupLibraryActivity.java | 8 ++++ .../app/src/main/res/values/strings_k2go.xml | 2 + 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java index 04fb57bb..9e27644f 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BooksLandingFragment.java @@ -63,6 +63,20 @@ public class BooksLandingFragment extends Fragment { private final LinkedHashMap selected = new LinkedHashMap<>(); private final Set libraryTitles = new HashSet<>(); + // Wizard mode (ADFA-4853): pre-install. Source is the OFFLINE catalog asset, there is no live + // server, and "Add" writes to the persisted wishlist (drained after install) instead of the + // live download service. "In your books" here means "already in your setup order". + private boolean wizard = false; + + /** Open the Books screen in wizard (pre-install, offline) mode. */ + public static BooksLandingFragment newInstance(boolean wizard) { + BooksLandingFragment f = new BooksLandingFragment(); + Bundle b = new Bundle(); + b.putBoolean("wizard", wizard); + f.setArguments(b); + return f; + } + // Fixed cover palette: dark enough for white text, identical in light + dark themes. private final int[] palette = { R.color.k2go_cover_1, R.color.k2go_cover_2, R.color.k2go_cover_3, @@ -76,6 +90,7 @@ public class BooksLandingFragment extends Fragment { @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle s) { View root = inflater.inflate(R.layout.fragment_k2go_books_landing, container, false); + wizard = getArguments() != null && getArguments().getBoolean("wizard", false); TextView back = root.findViewById(R.id.k2go_books_back); back.setText("‹ " + getString(R.string.k2go_gm_hub_title)); @@ -121,6 +136,7 @@ public void onResume() { private void buildChips() { chips.removeAllViews(); chips.addView(chip(getString(R.string.k2go_books_chip_popular), filter.isEmpty(), () -> { filter = ""; query = ""; onFilterChanged(); })); + if (wizard) return; // offline asset has no bookshelves (Educational) and no live library (My books) chips.addView(gap()); chips.addView(chip(getString(R.string.k2go_books_chip_edu), "educational".equals(filter), () -> { filter = "educational"; query = ""; onFilterChanged(); })); chips.addView(gap()); @@ -141,7 +157,7 @@ private void onFilterChanged() { } private void loadLanguages() { - BooksClient.languages(new BooksClient.ArrayCb() { + BooksClient.ArrayCb cb = new BooksClient.ArrayCb() { @Override public void onOk(JSONArray rows) { if (!isAdded()) return; langCodes.clear(); @@ -152,7 +168,9 @@ private void loadLanguages() { } } @Override public void onErr(String m) { /* leave the picker empty; "All" still works */ } - }); + }; + if (wizard) BooksCatalogAsset.languages(requireContext(), cb); + else BooksClient.languages(cb); } private String langName(String code) { @@ -190,6 +208,7 @@ private TextView chip(String text, boolean on, Runnable onClick) { } private void loadLibrary() { + if (wizard) { render(); return; } // "in your setup" comes from the wishlist; no live library BooksClient.library(new BooksClient.ArrayCb() { @Override public void onOk(JSONArray rows) { if (!isAdded()) return; @@ -221,11 +240,13 @@ private void loadBooks() { status.setText(getString(R.string.k2go_books_unavailable)); } }; - if (isLocal()) BooksClient.library(cb); + if (wizard) BooksCatalogAsset.search(requireContext(), query, lang, 40, cb); + else if (isLocal()) BooksClient.library(cb); else BooksClient.search(query, filter, lang, 40, cb); } private boolean inLibrary(JSONObject b) { + if (wizard) return BooksWishlist.contains(requireContext(), b.optString("gutenberg_id", "")); return libraryTitles.contains(b.optString("title", "").toLowerCase(Locale.ROOT).trim()); } @@ -312,7 +333,8 @@ private View cell(JSONObject b, int coverColorRes) { // State band UNDER the title (green = in your books, gray = selected); else the author. if (lib) { - cover.addView(band(R.color.k2go_band_library, getString(R.string.k2go_books_in_library))); + cover.addView(band(R.color.k2go_band_library, + getString(wizard ? R.string.k2go_books_added : R.string.k2go_books_in_library))); } else if (sel) { cover.addView(band(R.color.k2go_band_selected, getString(R.string.k2go_books_selected))); } else { @@ -368,8 +390,10 @@ private void refreshFooter() { addBtn.setVisibility(View.VISIBLE); int n = selected.size(); addBtn.setEnabled(n > 0); - addBtn.setText(n > 0 ? getString(R.string.k2go_books_add_fmt, n) : getString(R.string.k2go_books_add_none)); + int fmt = wizard ? R.string.k2go_books_add_setup_fmt : R.string.k2go_books_add_fmt; + addBtn.setText(n > 0 ? getString(fmt, n) : getString(R.string.k2go_books_add_none)); } + if (wizard) { downloadsLink.setVisibility(View.GONE); return; } // no live downloads pre-install boolean active = BooksDownloadService.hasSession(); downloadsLink.setVisibility(active ? View.VISIBLE : View.GONE); if (active) downloadsLink.setText(getString(R.string.k2go_books_view_downloads)); @@ -377,6 +401,15 @@ private void refreshFooter() { private void startDownloads() { if (selected.isEmpty()) return; + if (wizard) { // pre-install: record the picks into the wishlist; provisioned after install + for (JSONObject b : selected.values()) { + BooksWishlist.add(requireContext(), b.optString("gutenberg_id", ""), + b.optString("title", ""), b.optString("download_url", "")); + } + selected.clear(); + render(); // picked books now show the "Added" band + return; + } List ids = new ArrayList<>(), titles = new ArrayList<>(), urls = new ArrayList<>(); for (JSONObject b : selected.values()) { ids.add(b.optString("gutenberg_id", "")); diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java index 0d977cff..4ff1fb22 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java @@ -157,6 +157,14 @@ public void backToGetMoreHubZim() { androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE); } + /** ADFA-4853: open Books in wizard mode (pre-install, offline catalog -> wishlist). */ + public void openBooksWizard() { + getSupportFragmentManager().beginTransaction() + .replace(R.id.k2go_setup_host, BooksLandingFragment.newInstance(true)) + .addToBackStack("wizard_books") + .commit(); + } + /** ADFA-4850: Books landing -> the download manager screen (per-book checklist + retry). */ public void openBooksDownloads() { getSupportFragmentManager().beginTransaction() diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index af5d30e5..1903045b 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -469,6 +469,8 @@ In your books Selected Add %1$d to library + Add %1$d to your setup + Added Select books to add View downloads Downloads From e3e9bf84c544951371871b2abba6641aa63e2ab5 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 10:20:31 -0600 Subject: [PATCH 05/29] ADFA-4853: wire Get More hub into the wizard as the content step After choosing the edition, Step 1 now opens the Get More hub in wizard mode instead of jumping straight to the old content picker. In wizard mode the hub gates by the chosen tier's plan (not a live probe): Books only for Full, Wikipedia and Maps always, Courses hidden. Books opens the offline catalog + wishlist; Wikipedia/Maps are placeholders for now. A wizard-only Continue proceeds to the existing install step. Transitional: the system install still runs via Step 2; the unified install queue that drains the wishlists replaces it in the next slice. --- .../redesign/GetMoreHubFragment.java | 56 +++++++++++++++++-- .../redesign/SetupLibraryActivity.java | 18 ++++++ .../redesign/Step1SystemFragment.java | 2 +- .../app/src/main/res/values/strings_k2go.xml | 1 + 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java index 06d93f8e..2d9a8171 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java @@ -26,6 +26,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; @@ -34,6 +35,7 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import org.iiab.controller.InstallationPlanner; import org.iiab.controller.R; import org.iiab.controller.config.BoxEndpoints; import org.iiab.controller.util.AppExecutors; @@ -70,6 +72,17 @@ private static final class Item { private int probesPending = 0; private LayoutInflater inflater; private LinearLayout host; + private boolean wizard; + + /** Wizard (pre-install) mode: availability comes from the chosen tier's plan, not a live probe, + * and cards open the offline/wishlist screens instead of the live ones. */ + public static GetMoreHubFragment newInstance(boolean wizard) { + GetMoreHubFragment f = new GetMoreHubFragment(); + Bundle b = new Bundle(); + b.putBoolean("wizard", wizard); + f.setArguments(b); + return f; + } @Nullable @Override @@ -77,11 +90,25 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c this.inflater = inflater; View root = inflater.inflate(R.layout.fragment_k2go_getmore_hub, container, false); host = root.findViewById(R.id.k2go_gm_cards); - probeAll(); - buildCards(); // shows the "checking…" state until probes resolve + wizard = getArguments() != null && getArguments().getBoolean("wizard", false); + if (wizard) computeWizardAvailability(); // synchronous, tier-based (no server yet) + else probeAll(); + buildCards(); // live mode shows "checking…" until probes resolve return root; } + /** Pre-install: a card is offered based on the chosen tier, not a live probe. Books ships only + * in Full (Calibre-Web); Wikipedia and Maps ship in every tier; Courses (Kolibri) is TBD. */ + private void computeWizardAvailability() { + available.clear(); + available.add("wikipedia"); + available.add("maps"); + boolean full = (getActivity() instanceof SetupLibraryActivity) + && ((SetupLibraryActivity) getActivity()).getSelectedTier() == InstallationPlanner.Tier.FULL; + if (full) available.add("books"); + // courses: hidden until determined + } + /** Probe every card's endpoint; reveal the ones that answer (module installed). */ private void probeAll() { probesPending = ITEMS.length; @@ -138,9 +165,10 @@ private void buildCards() { note.setTextColor(androidx.core.content.ContextCompat.getColor(requireContext(), it.amber ? R.color.k2go_amber_text : R.color.k2go_muted)); card.setOnClickListener(v -> { - if (getActivity() instanceof SetupLibraryActivity) { - ((SetupLibraryActivity) getActivity()).openContentType(it.key, getString(it.title)); - } + if (!(getActivity() instanceof SetupLibraryActivity)) return; + SetupLibraryActivity a = (SetupLibraryActivity) getActivity(); + if (wizard) a.openWizardContent(it.key, getString(it.title)); + else a.openContentType(it.key, getString(it.title)); }); // Reuse the inflated params so the card's layout_margin (separation) is kept — // replacing them with fresh params would drop the margin and glue the cards together. @@ -156,6 +184,24 @@ private void buildCards() { row.addView(pad, new LinearLayout.LayoutParams(0, cardH, 1f)); } } + + if (wizard) addContinue(); // pre-install: proceed to the system install step + } + + /** Wizard-only "Continue" that proceeds to the install step (the queue drains the wishlists). */ + private void addContinue() { + Button cont = new Button(requireContext()); + cont.setText(R.string.k2go_wizard_continue); + cont.setAllCaps(false); + cont.setTextColor(0xFFFFFFFF); + cont.setBackgroundColor(androidx.core.content.ContextCompat.getColor(requireContext(), R.color.k2go_teal)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, dp(52)); + lp.topMargin = dp(20); + cont.setOnClickListener(v -> { + if (getActivity() instanceof SetupLibraryActivity) ((SetupLibraryActivity) getActivity()).goToStep2(); + }); + host.addView(cont, lp); } private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); } diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java index 4ff1fb22..aefa6b38 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java @@ -157,6 +157,24 @@ public void backToGetMoreHubZim() { androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE); } + /** ADFA-4853: the wizard content step — the Get More hub in pre-install mode (tier-gated). */ + public void goToWizardContent() { + getSupportFragmentManager().beginTransaction() + .replace(R.id.k2go_setup_host, GetMoreHubFragment.newInstance(true)) + .addToBackStack("wizard_content") + .commit(); + } + + /** ADFA-4853: route a wizard (pre-install) content card. Books uses the offline catalog + + * wishlist; Wikipedia/Maps are placeholders until their wizard sources land. */ + public void openWizardContent(String key, String title) { + if ("books".equals(key)) { openBooksWizard(); return; } + getSupportFragmentManager().beginTransaction() + .replace(R.id.k2go_setup_host, PlaceholderFragment.newInstance(title)) + .addToBackStack("wizard_" + key) + .commit(); + } + /** ADFA-4853: open Books in wizard mode (pre-install, offline catalog -> wishlist). */ public void openBooksWizard() { getSupportFragmentManager().beginTransaction() diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/Step1SystemFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/Step1SystemFragment.java index 16960a91..57af1a96 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/Step1SystemFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/Step1SystemFragment.java @@ -81,7 +81,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c root.findViewById(R.id.k2go_step1_next).setOnClickListener(v -> { if (getActivity() instanceof SetupLibraryActivity) { ((SetupLibraryActivity) getActivity()).setSelectedTier(selected); - ((SetupLibraryActivity) getActivity()).goToStep2(); + ((SetupLibraryActivity) getActivity()).goToWizardContent(); } }); diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index 1903045b..eb332725 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -450,6 +450,7 @@ Checking installed modules… + Continue No content modules are installed yet. From 7703e61316c40a81c65f0ad99ad2e11644b601d0 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 10:27:25 -0600 Subject: [PATCH 06/29] ADFA-4853: ZIM in wizard mode -> wishlist; Maps reuses the existing flow Wikipedia/ZIM in the wizard reuses the same offline browse (assets/kiwix_catalog.csv); the only change is the terminal: in wizard mode ZIM Confirm banks the cart into ZimWishlist and returns to the hub instead of downloading live (button reads 'Add to your setup'). Maps is identical pre/post-install (it stops everything and runs the proot), so the wizard Maps card reuses the existing Maps flow rather than a special mode. Books + ZIM selections are now persisted orders awaiting the post-install drain. --- .../redesign/SetupLibraryActivity.java | 29 ++++++++ .../redesign/ZimConfirmFragment.java | 8 ++- .../iiab/controller/redesign/ZimWishlist.java | 69 +++++++++++++++++++ .../app/src/main/res/values/strings_k2go.xml | 1 + 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java index aefa6b38..fa34a4f7 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupLibraryActivity.java @@ -33,6 +33,10 @@ public class SetupLibraryActivity extends AppCompatActivity { private boolean zimLangManual = false; // false = following the wizard/system default private final java.util.LinkedHashMap zimCart = new java.util.LinkedHashMap<>(); + // ADFA-4853: true while the ZIM flow runs inside the wizard (pre-install). The terminal step + // then persists the cart to ZimWishlist instead of starting a live download. + private boolean zimWizard = false; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -116,6 +120,7 @@ public void goToStep2() { /** ADFA-4848: open a content type's screen from the Get More hub. Maps is wired to its flow; * the rest are navigable placeholders for now so the hub is reviewable. */ public void openContentType(String key, String title) { + zimWizard = false; // live (post-install) path; the ZIM terminal downloads, not wishlists androidx.fragment.app.Fragment f; if ("maps".equals(key)) f = new MapsLandingFragment(); else if ("wikipedia".equals(key)) f = new ZimLandingFragment(); // Wikipedia & ZIM content @@ -169,12 +174,36 @@ public void goToWizardContent() { * wishlist; Wikipedia/Maps are placeholders until their wizard sources land. */ public void openWizardContent(String key, String title) { if ("books".equals(key)) { openBooksWizard(); return; } + if ("wikipedia".equals(key)) { openZimWizard(); return; } + // Maps is identical pre/post-install (it stops everything and runs the proot), so it reuses + // the existing Maps flow rather than a wizard-specific mode. + if ("maps".equals(key)) { openContentType(key, title); return; } getSupportFragmentManager().beginTransaction() .replace(R.id.k2go_setup_host, PlaceholderFragment.newInstance(title)) .addToBackStack("wizard_" + key) .commit(); } + /** ADFA-4853: ZIM in wizard mode — same offline browse (kiwix_catalog.csv), but the terminal + * step persists the cart to ZimWishlist instead of downloading live. */ + public void openZimWizard() { + zimWizard = true; + getSupportFragmentManager().beginTransaction() + .replace(R.id.k2go_setup_host, new ZimLandingFragment()) + .addToBackStack("wizard_wikipedia") + .commit(); + } + + public boolean isZimWizard() { return zimWizard; } + + /** ADFA-4853: ZIM Confirm terminal in wizard mode — bank the selection and return to the hub. */ + public void zimWizardConfirm() { + ZimWishlist.add(this, zimCart); + zimCart.clear(); + getSupportFragmentManager().popBackStack("wizard_wikipedia", + androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE); + } + /** ADFA-4853: open Books in wizard mode (pre-install, offline catalog -> wishlist). */ public void openBooksWizard() { getSupportFragmentManager().beginTransaction() diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ZimConfirmFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ZimConfirmFragment.java index 7e41dcac..b7155b90 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ZimConfirmFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ZimConfirmFragment.java @@ -89,11 +89,15 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c fitsView.setBackgroundResource(fits ? R.drawable.k2go_ok_bg : R.drawable.k2go_warn_bg); fitsView.setCompoundDrawablesRelativeWithIntrinsicBounds(fits ? R.drawable.ic_check_circle : 0, 0, 0, 0); + boolean wiz = (getActivity() instanceof SetupLibraryActivity) && ((SetupLibraryActivity) getActivity()).isZimWizard(); Button start = root.findViewById(R.id.k2go_zconf_start); - start.setText(getString(R.string.k2go_zim_start_fmt, gb(totalMb))); + start.setText(getString(wiz ? R.string.k2go_zim_add_setup_fmt : R.string.k2go_zim_start_fmt, gb(totalMb))); start.setEnabled(fits && total > 0); start.setOnClickListener(v -> { - if (getActivity() instanceof SetupLibraryActivity) ((SetupLibraryActivity) getActivity()).openZimPreparing(); + if (!(getActivity() instanceof SetupLibraryActivity)) return; + SetupLibraryActivity a = (SetupLibraryActivity) getActivity(); + if (a.isZimWizard()) a.zimWizardConfirm(); // pre-install: bank the selection + else a.openZimPreparing(); // live: download now }); return root; diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java b/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java new file mode 100644 index 00000000..8ece64d2 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java @@ -0,0 +1,69 @@ +/* + * ============================================================================ + * Name : ZimWishlist.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-4853. The persisted ZIM/Wikipedia "order" chosen in the wizard, before the + * system exists. Stored in app-private SharedPreferences as a JSON array of + * {key, bytes}, where key is the catalog selector "project|lang|flavour" used by the + * ZIM flow (SetupLibraryActivity#getZimCart). Survives the install; after install the + * provisioning step resolves each key against assets/kiwix_catalog.csv and drains it + * into ZimDownloadService. The catalog itself is already bundled offline, so nothing + * else is needed here. + * ============================================================================ + */ +package org.iiab.controller.redesign; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class ZimWishlist { + private ZimWishlist() {} + + private static final String PREFS = "k2go_zim_wishlist"; + private static final String KEY = "order"; + + private static SharedPreferences prefs(Context ctx) { + return ctx.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + /** The current order as a JSON array of {key, bytes}. Never null. */ + public static JSONArray all(Context ctx) { + try { + String s = prefs(ctx).getString(KEY, "[]"); + return new JSONArray(s == null || s.isEmpty() ? "[]" : s); + } catch (Exception e) { + return new JSONArray(); + } + } + + public static int size(Context ctx) { return all(ctx).length(); } + + /** Merge a cart ("project|lang|flavour" -> bytes) into the order, de-duped by key. */ + public static void add(Context ctx, Map cart) { + if (cart == null || cart.isEmpty()) return; + LinkedHashMap merged = new LinkedHashMap<>(); + JSONArray cur = all(ctx); + for (int i = 0; i < cur.length(); i++) { + JSONObject o = cur.optJSONObject(i); + if (o != null && !o.optString("key").isEmpty()) merged.put(o.optString("key"), o.optLong("bytes", 0)); + } + merged.putAll(cart); + JSONArray out = new JSONArray(); + for (Map.Entry e : merged.entrySet()) { + try { out.put(new JSONObject().put("key", e.getKey()).put("bytes", e.getValue() == null ? 0 : e.getValue())); } + catch (Exception ignored) {} + } + prefs(ctx).edit().putString(KEY, out.toString()).apply(); + } + + public static void clear(Context ctx) { + prefs(ctx).edit().remove(KEY).apply(); + } +} diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index eb332725..2334016b 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -451,6 +451,7 @@ Checking installed modules… Continue + Add to your setup (%1$s) No content modules are installed yet. From a4b383ff225c24ffb7d6f0768ab476f966db2b2e Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 24 Jul 2026 10:47:50 -0600 Subject: [PATCH 07/29] ADFA-4853: wizard hub retitled 'Set up your library' + styled Continue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the same Get More hub (no duplicate): in wizard mode the header now reads 'Set up your library' so it reads as the wizard's content step. The Continue button moved from code into the layout, styled like the other primary buttons (rounded teal), shown only in wizard mode — fixes the flat, off-style button. --- .../redesign/GetMoreHubFragment.java | 33 ++++++++----------- .../res/layout/fragment_k2go_getmore_hub.xml | 14 ++++++++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java index 2d9a8171..606ee487 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/GetMoreHubFragment.java @@ -26,7 +26,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; @@ -91,8 +90,19 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c View root = inflater.inflate(R.layout.fragment_k2go_getmore_hub, container, false); host = root.findViewById(R.id.k2go_gm_cards); wizard = getArguments() != null && getArguments().getBoolean("wizard", false); - if (wizard) computeWizardAvailability(); // synchronous, tier-based (no server yet) - else probeAll(); + if (wizard) { + // Same hub, worn as the wizard's content step: retitle it and offer Continue. + TextView title = root.findViewById(R.id.k2go_gm_title); + if (title != null) title.setText(R.string.k2go_setup_library_title); + View cont = root.findViewById(R.id.k2go_gm_continue); + cont.setVisibility(View.VISIBLE); + cont.setOnClickListener(v -> { + if (getActivity() instanceof SetupLibraryActivity) ((SetupLibraryActivity) getActivity()).goToStep2(); + }); + computeWizardAvailability(); // synchronous, tier-based (no server yet) + } else { + probeAll(); + } buildCards(); // live mode shows "checking…" until probes resolve return root; } @@ -185,23 +195,6 @@ private void buildCards() { } } - if (wizard) addContinue(); // pre-install: proceed to the system install step - } - - /** Wizard-only "Continue" that proceeds to the install step (the queue drains the wishlists). */ - private void addContinue() { - Button cont = new Button(requireContext()); - cont.setText(R.string.k2go_wizard_continue); - cont.setAllCaps(false); - cont.setTextColor(0xFFFFFFFF); - cont.setBackgroundColor(androidx.core.content.ContextCompat.getColor(requireContext(), R.color.k2go_teal)); - LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, dp(52)); - lp.topMargin = dp(20); - cont.setOnClickListener(v -> { - if (getActivity() instanceof SetupLibraryActivity) ((SetupLibraryActivity) getActivity()).goToStep2(); - }); - host.addView(cont, lp); } private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); } diff --git a/controller/app/src/main/res/layout/fragment_k2go_getmore_hub.xml b/controller/app/src/main/res/layout/fragment_k2go_getmore_hub.xml index 55c66a12..84da4bb0 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_getmore_hub.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_getmore_hub.xml @@ -16,6 +16,7 @@ android:paddingBottom="24dp"> + + +