From 0db2ee458779fa83b885b8e63324e9e31a114ef2 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 28 Jan 2026 15:16:08 +0100 Subject: [PATCH] claude --- CLAUDE.md | 63 ++++ __pycache__/gallery_app.cpython-312.pyc | Bin 0 -> 65032 bytes gallery_app.py | 379 +++++++++++++----------- 3 files changed, 270 insertions(+), 172 deletions(-) create mode 100644 CLAUDE.md create mode 100644 __pycache__/gallery_app.cpython-312.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7e65de3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Turbo Sorter Pro v12.5** - A Python-based image management and sorting system with two web interfaces: +- **Streamlit app** (port 8501): 5-tab workflow for image discovery, collision resolution, archive management, categorization, and gallery staging +- **NiceGUI app** (port 8080): Real-time image tagging interface with hotkey support and batch operations + +## Running the Applications + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run Streamlit interface +streamlit run app.py --server.port=8501 --server.address=0.0.0.0 + +# Run NiceGUI gallery interface +python3 gallery_app.py + +# Run both (Docker production mode) +./start.sh +``` + +## Architecture + +### Core Components + +- **`engine.py`** - `SorterEngine` class with 40+ static methods for all business logic. Central SQLite-based state management at `/app/sorter_database.db`. Handles profile management, image operations, staging, batch processing, and undo history. + +- **`app.py`** - Streamlit entry point. Initializes database, manages session state, renders 5-tab interface. + +- **`gallery_app.py`** - NiceGUI entry point with `AppState` class. Provides async image serving via FastAPI, hotkey-based tagging, and batch copy/move operations. + +### Streamlit Tab Modules + +| Tab | Module | Purpose | +|-----|--------|---------| +| 1. Discovery | `tab_time_discovery.py` | Time-sync matcher for sibling folders | +| 2. ID Review | `tab_id_review.py` | Collision detection and ID harmonization | +| 3. Unused | `tab_unused_review.py` | Archive review and restoration | +| 4. Category Sorter | `tab_category_sorter.py` | Bulk categorization and renaming | +| 5. Gallery Staged | `tab_gallery_sorter.py` | Interactive tagging interface | + +### Database Schema (SQLite) + +Key tables: +- `profiles` - Workspace configurations with tab path mappings +- `folder_ids` - Persistent folder identifiers +- `staging_area` - Pending file operations +- `processed_log` - Action history for undo +- `folder_tags` - Per-folder image tags with metadata +- `profile_categories` - Profile-specific category lists + +### Key Patterns + +- **Profile-based multi-tenancy**: Each workspace has isolated path configurations +- **Soft deletes**: Files moved to `_DELETED` folder for undo support +- **Parallel image loading**: `ThreadPoolExecutor` in `load_batch_parallel()` +- **Session state**: Streamlit `st.session_state` for tab indices and history +- **WebP compression**: PIL-based with configurable quality slider diff --git a/__pycache__/gallery_app.cpython-312.pyc b/__pycache__/gallery_app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f027696c08605ca1192e223a05553682b9b49942 GIT binary patch literal 65032 zcmdSC33yxAogWGi0I`!G2=0r(ouIgj6eVhd@Riqfy8l2o(q&CVJ~0|}x@$U4 z$LU9Pr^H|VDLuPSJC(+M4W|tFO&c|inNFF;(odz2nNOL=ET=4b%5NC8j@eGx#_Xr; zV~$e}_RKiy9LqSB!Q!UT%rVy~mtL2q+pFW!U)FKvH`2t~Pi3X){8?`#nsUmGJNrnr z)I{Z%v%H}f@3?K8)$ieK{v6Kk&lyPP950*X_j8~4zEgf5Zx5t#87~{Rz1%v~SjKq~mUCqYE4XrmmE2C&y9$)!p?bMWe*ss8R#~`e zlvkCgvj$~UbG3M*hVvn;U}WvetsG2pNaPz=(xsSG+sK!tC(5ss#@^0Py0eYsEBT}tx0&nYws5Z7Ti-B>&-A(} zy|3#dx;f%A#w|zB1cxWa`$pr&L&Je!+}JbRAB-DL_=7$}JZ*9~Zs*tY+4_CjoOsjh&%lJ+R7*WGDW4tr zTzHa&Tn@zDQ%+w-+|qUC%!y!M&>w2<_KyeozR}^3pYx)}qr?4uG%nr%C40{e2Zy|) zeW5Au=tLhkJU-aipV*|Vr!=(G>rW10vtQE%<+PE+=4ZJQfz;z^U9X(V>2W8Gn{HY+ zl9(*@djSWh=hE=ca3gJ)^2=`{zXak`FG%$OFd32CBqebs+@;HR>9{k?cV^sKCTaK=alXSDJ?^~<7~Lgl<(}gbIErO+-1pkPTaYrJ61}z zbjRxRNO!^YQcUVI-prMrvAlWGon~Hn<7lSR;$s&psZhFOBT$rdSDbWLl5|&^bhj?) z&YN^sCf!Zxd>KdL<{tk*-{ffUk)xOmC0@n`i`&Ni=Yze&O_J2Vk| z$v+i{yTwTF>8V~REpD3}=O%iwN&8=l+tG4Bq~5U!&L6j(?hE!0iE&5&sK0M~@{D+w zN!xo0d$@OSbmBAspAADIRvL)gQPd#X!1V?D;)Z}9O&sL?{_$RJA{dBUSxbAz`p(2N z(1!jYK_UBDS4L$NBe|#&4>FevDLHewggq}@Zf>RYlI2!Z8MUlKz8W88K%|&u$){Hl z!#l<0Vtdx1B(couT9!~&K@aj*Dr>&Kn?3NY$4|ye?fN%Nlv1=(*YF1GOv!S42R$i4 zspZuaD_t&S#TG4BqOho??AKLj0D81sgF>Q~O8IvkYFV-re}WOyX`dT^fs|_m_bE%t zH6ou2N`m$YF|3o2iwpW`z1Dklf=GD)Bju8xkQ?W+kJ|yw zl$(KxQ4Y(W0FW<@-%gp+&#*ljAc*Jn@;mUu6HpBm%PUm_QuQE5;2?sFx?gAITr0U+ z63Z$VvdZV%Vij#dMcd-(NY>_=^yNbDt994w=3iJm5GmZj7DiFotDV<7=SLRnB1PM1 zdh?fyN@!a1`7V^FJ*^@Fq&=;CTTnj0wT}A{`A|mji3=skhqLrd_k?95P234e*LdrM z&ZiIU?4B6IFazfij}kV6onB=knp8vYo#4GAlYyWrxzT6h_o5~7w21(n0>PoUab#k6 zoZpRy{5Aw4WMT6Yw~3YXa>INmTb?H6Ax%tRBZ7-M>@tTdW-q#DFIvgYxz=&D)?GsDu1I!w*wKx-h-I!5 zGS`JI>mHvC!ay08{6m}?Pl9$>tn#)%{={`w&leE(rIm)KQa07^R>YBFR6R;WKT9c$ zFx4l@l5u9G%+Fj_8kH54aq1BnpH3=MxshW}?2AULZX4c6Kz6+@DC6NHG7c^m$|wV^ z(ff{$Gkw>7a$ugK5MAzMBiDzw||o7K`$lXeQ-h>+7qA$s%i`ZQDx!8BkNNl ze(9|VS!%oil*N?+)tm?n6Fy1UNO0vO*6*J&RORbE|yy(;LlRKP$F15mOHk@I`#@3 zdmm3O6fA8k7EjDlAy_Krdt#OCLS_4%p1(Z$qod*D&%})-nSfN+bo{W`-7AGc+x3EOMq>+-$P_AqEvf%Nj#17`hWps zq<&Bj#6mZsvE-)p*rK*+pck}dl2HqsG=|WIz5bw=&{XflfH#4+4*LW3UjKMtk_R@P zSVM%`d;RB;G0?~p)y8NVpHU=IGJq^d$Uoi|S4kyuNoau)uLqKlf)|h)%9Ce2xq#D1 z3~WIFBsQ}smRToc)-ANf)^8QoZ;fPbyO(+BuJ11J&SiV%0FolXUNkp(&tAElop)`=)g56+`LZ)B<}4AM zCG+|BoW5UI)V#jqdpjc8$8PSJ>t5`?b27H=DPh}F_clKDt?v09LiVw+ux}Kn(4b$nD5N<`td>R*fSCAxG%;=*V>oKRe|R)*81;|y zWq2yhxkY+4Y2xjy7MwPyCFGF-iRx((Wdzz0Fl@<_AIn}ZWUr58H_TXmwYBF`@bc8` z)Q#-9=e|?2;CZw3R;f_X5y{*Xwj6x748h~@!-%37@M>Tu0_>{nJoQj#<}GLE%3e;OpK7m$Vw~of?i?xYLBIy zQyg)OdI*Rgr@vufE4p1CU&e06**Dw|G8!YD;NT7;?ETND)X~fyHuT%aO1UMXB*;jh59ld%q;;Ni6tz>zw zoQ&nv3OTirEFZ|_g3^WRJN~-^KN=S@4~H#>+0Y*GWfKF5i1yyzxUHACZ+r$ z3CaQhEmC7z2S}rrq)@*PS?X8Nhd)WXX81#sNq508ZQxx>z0(HH$eFnGVLeEYL|;+A zd~$FWb-Oxrm&*Tl!#O<&l#M6+JSdcB{imPu%3~+WZ@4L}xZ!?pf)VZQ8|N6FK;oQ_ zUcsRT`h)nt!Aatfa{eQaYz*OOI_E!q=8?0T_`MC?A~XF_{#f7n2Ee~MyJH`n8^ z8Jh%AGU~r&5IOFkkK(p5KR4XhOEgW~Jml{K6D#o11FFfQkm>Ps5#8m97UYREj9dH9 zO!l6}Hs?gN*VPX;4et*GdI1!A(H!6|jKV)puS~I5fUkfv9z-VEBmk}=Q!MVJJuZ&~ z|3^st$M_%M5&U1ezq_b=09H%J<&N2o%iCwS&s9e4>sB)J=DVYrwP8!`N^Zfmv8!XR z9g0DsD)D^K6mX?!cc0`ZWwX^kdZ`Ds6|K>K2#~=pGJ{=YQTI~SMp?Y7swWWzgGy;_p^`4e zMoOfZ^7}?2ig%T9C{@y>n5twA+Jdv*wn@EJaw?*5RoPytMCDg`Lu>7*QZ?pRm8vxZ ziPMfKsZTir_Gvw4!134&C?i|0v|Ng*T2U!IQ+{9iB4*ysnUq|LU_MwQ<--`t+D4cS zpY&Xrp(&#;BUH|WLkZ%YNrw{B2C$#~{g7!-HHO?Lm@L~1MjkLe#AE=Rfj|KI9XRr>48#Tc`^J4a%BjqM19dWT1tR6ch%-hbPs~9Py>6lyL58@C zF&Ptr5^9hr&T^B~nJ4rb65YYxJ|1^OB*slF0fJ?KS8-6{*#PL&TAc4p(#1;gQVoRI zQt|+D46(w9`+)w&ZPLTZVP6_rO3c)_B~cKU1j+486eiZi-$aT39RCAtKtzDr+nv(k zf1@f^SSJ+LMeXYoOvkXN`nAbeO@~m^5w6}uJV)!2<-oEf<3YO4;*6Q|?wRv$?2Z*T z+$(NaaTUj04T7uTwm;UmU1;3?Q&Vi`abf52i0cGsFuUtY%QrW#0M*z(yMN9;zk9x8 zp(>o)ym)rWu^q*kvSP*x!B`PBR?(d;W-Jtpg)yU7FnXiL@(=SWVtI{1UgPb_Skn%n zX~(;5(Y&W#-W@f1kR4TLHVK(cKQzUfcM8ore_9pWbzInWJbLo^*vSFmx#hCF2hZugohyN3dVG^<;A4o22JT>{Kg@Q|4BuLmAyA)}xA|FsvwV19WwER*o zsa)y!A~2$qqUil-)}aBcXnLM%8w&@{SZEwk<}bBv>Im!F!B3 zFZGB>u8aChjngJoNgBPHHf%)^mv*7|l~P$+imM|s_?L1ewXsflD#cXwdZlN|uXH$H zfKFP#yLw(#YQ0i|6oY8uUnuKc8r9X`QKfpNa+F^hku^qx&WDt1+ANJdKN75x5=WY) znDVRS#M~dpO1Df~Muwr%t+o-Z{UfU?V|ScP+8r8o zjL_kia;;wP<8vmh35?1~8Wq**mfMn8*I7xsTVq|H)ni;L*O*0(H9c*fwn*(zwO^x# z#LjufsGBlCgz@szXP{Q*CmtU(#=N9r#&{4+S<4HhvOuHFMudA8KN#p_F}jn{lTPpc z0q;qE(qB*XAMh1o55C0sgy0W|nr2>bVf}$d@6q6ppFcMoAbAMnwZyt;WF^K8kdVcV z#Ctp9D-scyOu|IORxza-{u-ug>Y9i@O)h=_QZNmK~*5IQ4_VN43R zd=HX+R(9~pih4{l&`cV9q^xHc=FZ10#0p`$DAoynSd8-D#=CI~`YxsrtKUz95U?VF zhM8R=v+%pr3<~}l{{t?Z0^pK3bOlwh{5B!KEt0>H@Q$1ncg`z*QQ8~Jtrv3ZBe{(c zchkj#_o0GTLs{}yGO}K&in+@LcX=eE;^N-rlFIq72qmqQSeQU_Jc7|9B0EuI@k&AY zYwfX$4MN3+_Y2x)oim1+=a%i+SH5!3UcTbUiaCk}M{&&I6&&9A?AxX{GjC-IWgSb7 zP4`W@!gg?6OtxAGlc@aW~FDm%}~#)#aMzS4`J3uV&60?zt-OTXn_l4>EKv_vLf5 z=PpmrP804|FBI2HC|@LV$HjeM3%#;C=BX4smA7}t>URnF^X&R?!^S^rzhsJ8N(D>l zjmg-$CINqz<`o71oU4u6D^`k2Up;XB!2J35i<_@lma~gu+0{a}jD{{{Z(8vb#5{F^ zr|x!pZ2cAif1a%|&rZR!^KSl<=Kw~cqy;09Qz+(&c^U`|X_fJ$q*?D;dRe z&qXq-XAH~sjM*J?f!kGYHr;Bvw{G)FMb+yaH#=V6dUNZdM&z2#s!|7sbRa?9AxXZ}N_pZDt;L_^3=%l! zj0B6NyA!$!6%8GfQL=%wN*!;|>z>z5na=6X8=lvJKbjuexm(Rfnf2B8T6 z$i{nD&#k565A{sYaCCvIWMGF(Z5PKurve>>bks}Q(RAMQza(-9NqPV+44f5>JnhCkC=<+_P~|n~ zEai;IVgQ>dfhWzDyu)C0^|@wCLIFu%;&NKQfoV)Y;KGOsJ>;U0i6#(e2h)eE|9K0KrPak z#)RPlv^dU@rcKLn{}?psfm%#?MU8oHA9x*u5E1L{33P7HU{&6J{SRT=lgNm%NI=w0}J~QLJ+V28ff70Ox3ih zb0=QAkfy>4wJ1HpI8rVJe253Y1%s*%YH`X1cAsH7jWd47IBfuf*?7UYMx7*0Ou1Aw zXwdztRE_$ljgsi%f=QLrl~M(6N3tJ(rrLh#{b@r`^mzg7TSnQM7-cKJqNN|1*qDW& zXVWS7x6&?ou|3Ur-#BdsFJRpoW1+!~m^Mj(l;5TBBQy=_%+p4Uz6H!y6$2SSf7LcC z^Tip0MN(FU%Hz^Pc=#P1o6U60rp)%{U?H`7+2}QF+HS}_KwD&9!iZO@OZi>9JZWzA zSn(G#P&#X!(u&^1p#vJYLMm69Q`9a~w#u_9sHHKru5^GXXQ=9AAN2jkMGYNEb|qT- zC=^lS5r${*{|Bmw8=5z8{Pz*#UqcYGK@2+p8@*tsH3wobss$YT0fq~)kjr>*0 zO`PMn0R-1A6HYc*ElrGz(yqIdN@amaA>_oDEuMkYc!f5JTNpA!luIQgD5MHeNAdKt zgyRGl$)e&9)6vP!Ly^1^ZHh!zFC#1BZiRK*I|d7zvA$8Lyb>tl-=W}5D%?P|vKHC~ zU=lPc5-$3)aYuqwW8_PKU{r#7Nl1K|fz`w~StjxCBQ=L$?Fa&B%;cAc^BZUDez-f< z)+4m_T;YNf_FT)mns=@EYVi$!th7-mZIqa=cPk@VdoLbXsi}|EY!YfV#cFm4H9Mj; zP}n~$)O5|4-92`9@~!8h$DWBD>l2RkMRxaH>AEuT?fv1Ju8W7l*{6SPw$Drp=As+? ztLLttd-eCO|K7q#6nJ?;5%$1baBd)6*%@|kx$9f999nVbi6mAyt75)o{=`D|{QiYw zw*!k?BTYN+W(iHZ!j0W`yYIG#GxyIJiR#K*cIPd-@);pPWC~P+afi(X%SEO0S=T!*nP3P4=5W+luwu+zGUos8;|86l zUmtK_+<#JEv}c|1ZHpOU68=H}l^rYpCnBWZrED!NT;i;b-?(~?X~v=fPC_hbc9B7; ztc|PZ3=FKArBtM_Clb)(j7i`_KA^O~uX0pjtINlVvekoxa-f%^7B?#)AyoE9viVjC8b)t*>H<$|$%epA#~zoJ?u47*KQ z6Et&dO?(LvweWyul1sax!t__yu1uOAdB#<9L9D3U9{;ExtSBZDVpt*H7Z_@cr*rH% zM(;f|gF#9diK}2)rkH_ne=u_bLrj_k3V{Y&O(8fF!z^a!{~eO|&^Xs4!cnhAd>gY=SPioE5^(}5H-pA@sfPWI9aiAZR9_YuWSh|2?~+kYN0hm z^KwO6TTzP$lZI9lnbSt&(g{c5Pp&RF$F!O$x3medo#gGS*;Y$X#?wYVC8m{H2UA9{ zs(XAzKmH7IF}#bjv37q!)ozbG9CAb|6Ti$ZAHNRP4_dys*6fG#e?&jVKaqQp!FutZ z)~F>2T~8f9o;!>MIR*cZV0>hmq*kxHW!g=(xm8HXGJcAa{W zX+UEAC1{v6=gqZ7dp18}*ewotlQt%prkWt7i%i!{c5CO?_^K5u?H< z|25TSr{Gr<9H8JJg1Ch&AqWlR!}N@>)3_O2bN1*PbeA3&f|?jC{)r_Z9*_S7&!I42 zq#=phSiKCjOgbj*@-fLx@c$8S93$u*LO>b=l4YidkcHh93&6wLpcWt|fxi>8uM_O+ zBKES2J%r8e``Yi#<=iuuen{p{)qV+Yxr}yn8lgIr3|(b7p9^bgtu`wVV}0_TwVIdLg)Y02%>zJ$DD*Dvnx?NM#;= zt^DDfWR-wFgvF%R!54J|icZSQl!tPz~=Du%vN-A^HJ!{2>IeFKfz5496FJAqk z_}c4DH=7nK7tj8nK2pBpZsB{!|K{1BKN~yzf^hhS@Rv@9`-g=ujYJN=6v-K#v8kEZ zP@kw;akwuZoIN;a6Y+wmqeW7Du)Wqw%w>_qzu0pp_Xmd}63dSNEfQ=fThsur#Wk|k;Ru6bCl!4?BUC><;$vwCSN z8i&&8kgllD5?T-2jL|7cZrnfAH%9F}ZtT<>Em)@KuspixXx%G!)OL@_fwG{CirD6y@lZ0jkwi2y2iCYGpPuvv)( z3%SDR6iym6tb0rqwP-Tl5x)}6vgkOA)nZ=i9e0`1pk?-3Mf*d0`XFD&sK+AP!d-!4+s)hhsW|# z*V3L+>er$jRH<&MoyxB&)uTL>VyaXUDWzPhR4p~&1b-anAfnQ|K{N-!_L8w%WizY+ z=`G;Ms?@AGn^Lzj=2J!(i?meev{NNPDKjRqhswo`2knB?%_q7JqW$LG%e)9Nr(sjx zzEP-oa#N71(&mEepg=&i?M{Z8PLMhS;htg%;%kkKjoy<(!vQFPKy26d5`;J2b3@Qj zXk_O_C^xANG6o^)y^L+xJ-!Ta$2MT(RXdi}3REE`)OIA!#0D1YPTj%ssl)a%e~!xQ zqF@IVm7TPW6D53%ls}}6{O5=;YmyJmj+nXlp1D}w!)4m19Hwe0N?abD9bKUVs#<_dN78j*ky61?n*4;+AmpS^oaRj1L!H?QyAuRp zyjVS#oN@!|77LdwTYv4Xx@1~{|DHT#`5o-D%BtD@e`6+YU+=Q_81!#tbhUJE(EVhG zzPrWvwyp~Cx2qgTc)LYU@eR%%o#CfC1D?S4Za6p!KU0CBiE|=1DQ;!)k;$<${YH$3 zbkR)E5%@_Vo_<63jdV48M~llKt@w7`H`5ikwQcf*rF%UIkX6R}snmiGQM20vh zDc7py$gHQ7a!?U`e z4EMB5D!_u>GLt@|gZDtw%NEFIl+o0Hc^o8BisuY1%{j%%=ZFmFlriC~Qv1-lG`VIQ zYBsG=vwhmMM$HbXY|Wb0>inzsF=IAeO9yt1+B2ov;ZDLu|0GJ_@q1NIHTV}?)-2>@ z-<8*<=4x?&`r6Q&GGzv&n6xi|h`B0sEkMN2L5}r2Rt<#j&x=Ymk3E0k3C}y9@O*nh zIVU@`=_ph$n8IHJPL5;?FBlSzP7I2|vWbMx&ICsz&=~TaYhZ?9WR-ZXA<$1g>l)6( zHol?RJJ?6z>A{B6qfi71ZBJw+S5EMm(s0@zJcry~rmNS`58V+EIA5W0YZ>nK) zbF()HVN?T-B)H1V8-NL9!)XvgU|LL$lUUz-=6pkI$V3&j_Df?&zl7irTj&#FwEuD1 zS|qBL-qWnS<%w2Mj&v;+DJg7U(9fo=#-;_}u%j{TQrKm9D$6po&?j)=!73Jow6Nzg z+%_r;7j7C&M?#qg0QW@|3uYHD3NK0iz=V)VG~d6Wr?lM27GwhM&MZ(~6j5WD9E5LH zGbu8XMkBmE@}#3l1%pS=Rs!PyCDbl1UKG!0C!IaSn`Z<-sAdAosW&t+F$R}gw0au1 zj!yLVjRv+iCS|;ZogO%j=KS47-AZ|aUlJ?dB$RKuI>B!{4H;`Fmxh<9 zbWvm>6_~Wz=+I@WZ3Gdv+GG$mD<6h>95q^6c*<5^1IU|EEGPoBV(tpAdxupDL46y{gX zpN!_$sisMso`%Jd_w&2NDY1_Bo%WCRP7Dk@DtyAY!+44qm`s@nj0V<=Q1KHoGuVJ# zAlvcoAPwAYGGH2N=!4;wcjEL2EWR2r@#Fv#`0n|Jp@uJPYCk*lf-g%PA{v`G{P^%3 z45`7Sgryez4^Nznn^=<-_9y@EX%v}wkT@yv^wX2UAXbzKnD?1L+#H;k7!3}eVeN*L zpLrEE(un%ZZ0z|@==}#2{4E9lodQx}W789$o%kY?tnU`&#bR+;8W{vdV<4viX{& zth#@nT|Do(ofGqI7VuZL`ES!qIVbfGGIZc)?!US}T+wm2A?kVRgM!lQIpK9%BL&+& zDDYm-3zuz<7IgkLUFRwN+cZPYE`%O~B~q}Jy)a)KEol7E^__Op-tE?RI?c%QuHLlIW=@jD$|A;c5n)gJhT$8= z&vkamNG*gpw4T_t8UhZKKTVZW1I3o$)QEguN#&9)vV`z3R9&f2g9Xc_5dy^-zAK-v znkCApGET_V%aaw5G|NesT1HAmeES+@ncmje~3qlGJJ$#aE4(b(%;P zAa!69+odyI%aDsx5NE9#WyJZLh8f*V+Kgeq0MGlDZyHrS)gTq59%+u5j4Y?jrUoO0 zbHP%;2=XNBWy@{%s{P1TWl45I=pYy59&z&`{QC6N(7MD-|q@H2E z*0@L8WdW$va)s{4G3-sv%^}wbI5^wKTwOEPZh2f^9x71b+A;?29XkzdyZ$L+E}=}Yf(T=W zA&j@StKpQ7kR(yz7e9&WLxl{1XXrfKm5AtkV2ny?3t78GcaD%zx>+_txEd%MtH^Z{ z?n~r)z^dZAF`keh+TmQf-#^48B5@N_g$tE%ef&#aI1(L&dus`eqmzGNqJJ{*C|#sI z`jrV0z1J|Ttmyq%g|6MQPBj4?SN8(29(o+XP{?f zchSI!(^MsBoEFo%tr(a!T9KVJA{yuh$_e@~5TXzwY|DnS6PcLa9CjzcB$2^f{uF!_fZ!5||2{7%(vCuFLIf=g ztS~mV&&6j0)dea(aSq<=P4L781bYxI0I?eA8$C1B$JF~pth@%#`JYfgZDa-n3Kax} zKNy0edG_X&ZoNAPzSb<6J3n7dMNSH|3Rg1au_u8+A}1$S%2-8R!5 z&vd^MnA`B}^Ybm=y0DP_X3?#p+a({m)-UXfy4s-_>d7Nxx@G}?+084Z6|vGbp|oxB zgiyNq%HEayl30GDkl(lv2eqdi={F`C`6ym>1b*wqW3`i18N&&HJmR=#ik#Ou%BeE$1im_NK& zcxT_8&T!$LcTX+3o?CY1UUOV^ypplvDqV7Uq3b%A_N@Y`>513F#Z8Nrg;2P#^KR~v zYhTJc3CurSxc%<)@4JpNA~IBx!b-va=L%~h%Zyz8*E4Ppelvv<p zMge0eko6Nxx9*~lc!uOZQS70}U{J<{_Q{LhojVhhto!l7;OkJ@Pw_u6h2Z~XI-0pf zv781Wry-WpBIL9z)<$x+Ts*Q0joZEC?IEcP$i8rP#r(ec&V^&)tfq*iIcC`;ST=<> zpNv|bCPf0uk+maY*%`C!6D<3}`~6YNz`wT_NW}2ed}*w#T_|ghly!u?o5DGpBaY6P zW0&CA74Bw~a9+W+^HI4=v=r zS$wNF>ev`_Y!w_^?>rNA^hk*V^IwTNHpCp81;^$)4N=FwhfZC_j)za{b!EGW(gl-1 zrgXEA5_WEXlt>MGdlCfhHq6wW%6%roTgBZqdpDc@%CJknx5505(Sn_*^Pvgwv zdd_kIXLwN7_YglN1tTnl;?=-rg0dlzf>EYHoTS{TC`uXUksAfI*=gW0oKr=C+m%!) z#$`x-hR9*u2zz2EE#sfmUr<6R_fz`EC>ysn1v8~jP%xRHjmOyu%#OA(jBrY9KKJv=kC05(=tr zv+|WLMI0fWew3G3&1v3>lQ{4mph1jp32r9ifws~EGD~7?Nzq=Z3b$l!#E>g-M%hP% z;1_n0lB~mlgyRRkgGjic~9wq83;Pz5dmkUtJ6cHCyKPE!Q@aS<-HyZFjhK_uT%K;`(K8?Mg+h zs4E#RZ(H287+Ca&y<3;pw=LH-{#UE1gxt0^Z@95LT3o+U+_+TS%#LTKyMQsGKJgo= zW3(1t^osp*Ki=d}<7ew4X$mpha`fmynj@#>exa_o;>r=R_6pP(N*7Q4kQ2ys$iEH5 zHlYME%5-{yCq+W>N0hUH3*d8zbGaGKWzIDib`UqxE@YTN+{Bai7jv({%-8W`rRow3WM2DXTULCmEaMXOGVy^Ey)x!F1 zclsjhcSc=ZA7tlWZJ*mXUp1e%;0zaRjAVCwkX?ASBV5$El)dG*4Z7TNu!S?WA+S}2 zt(6}*w}zctiB-&Q9|dUDzooD4(Hq{X>Dt=eZhE`Yf}6M7P2JnAZ+Dsz|Eb>81FkPJ z%9ngro&`wzhWUSr(^77nmUvqN3%fKm~4lrwG zcNW}PacBFkj4^1o#;%;L_$;Ij?!*lI0afp`T4m5u=#cu1^B)e$q*7OJHz)IqHQS~- z_s7?06PNj!>UBx&TfJT_E)16?z56)caYcWr8&05HHI(uh)Jqj(QuCMJHr?f zElF4=p{xvT97hCj9w(r9DPLqGt4nF*6ny}oc`{O|rGIi(f^QMk`1gv|_G(Wv~ERx@`lD~fO%jBB#8DZx$;f`mb`OmH7S4Z+|e`_|@=PC#cn0jQI|>J$w~}ACuxTm3 zEeXSNwS4C0bWrU?(^^! zquGb853-6l%q?m)66Nw^4EF|p(@e{nN-tH<;ON%DuQVuSK3fEGsSiKZgss`d5Aq^z>G=h%vr-4`L7ou~Vy)I{2p)yg&i#FNh2s-$Mbh z&_vEP<7_kbwVlc#_BQXJfUFncMe_WazH#!&?X$#fi4M^>-oB3l7^8v=5Y9sXF~Q=d zkE`*tJ=j3;2M|*`L)j@3`yQ?F<4+)SDUB65cf?L%lyBudczhs#WL1KtDrQ+PSk^CD z8YIfMI%2H`g&WK92|2z<&iWbKhuI}?lb8=mH{r}MziDPa%c5eMS446)&g@B1+=6!G zQr4b?+-2F5w_NS}SZ6HFePA+YxgTceKmb?WEV$mfm>w?dSUmT0-;d6PpYIKCe=+>x zKsbLe;vV|IU2wG~T-X$KH-Bt2WL5murt=i8cydYQYi{7x(d(n%85cSZyt^UNaa7U% z+8!y`@s9Z|^Sh?M%J?vmxnHQ?an}&3?}>VLCv?7)Y;R{Mx?c|)bnd-+A!`r8g;EVh z`pzY17kO{ocYR;DWmmMg`-809t2yDk`bbv8n}fFo7hj4r?z(T#IpO)zf97_?i8Fm!(Crka(?mS zG^4ZZfg@2wIJahD^3GSIj-xThvx4K<@N+Ll9eocobr~<{AHJa1*=oYpY7DH{fkzKp zsraz7>(N70@d9FBd?aUk6nGXZ?ya=0<^xTJpR{&+57^8G198@57 z^)@TCJ%QJhi<9-yl61iSA=)hKiYO8Q%{H&5@1@mZm5!Whlr=Q7q}nt`S#~n58f95h zqCC^m>`GZD#VJFITMkBcOjC~VbX;0)%auByGY9y}@@e%~;eV8gxzwRr?!je(-c{=# zmaB7$an3cZyPYN1r&XfNUjVh6P5-3aNh{8qw-v1Q!3TO4yYzdh`h*NK@Z@xgz;8F zwj+4o%e?!F_Aa#DAzUK;g}4E(?c!E^aixEl&^n&j^88+;gq%rQg4*Q3_mF$7Gm&po zcryyr(K-QFJhCG~s<9_4^M0P3A8$U+WfKf5&RK3bk7! zwc8_=JMNYVmEEtHpxzUH;Y;k7)j3I>xc}-aFY_dodZZ@bM3aq4a%=RPOAEBx4*I7~ zPr&fPdtzvU5B5XBDsF)u7JJ4Yq?z-mX*y}$_^kLWtD&V~bEufiV1~%m4Ajd+OC9pu zE{ctzGOBM5Wm6XRaGchT=zI|=Plr5DlTWtf*X!t|Jt1fJ6qMG;VGdMX@hp_y9pp#X zp9on=!-jd-kRP53xtQA@mCGM$9#msPMJG^k|B%eX@YZ4`ynUz9V4p8-l3>km{_u+T zH-Gr8kQ?f?XNU2npeDA&$?^)NSbI*7Hi*_rndnE8*kaMzsx&b)sdjbZh7E-BS%e9| z870g%;n>9Z#3N@S1-5qW*lxvjljCsW58Yms+g=G`s5N;vN}9aqVL2XbXpwwaZbUy6 z>7l4hM8sE|WReil^Gv+RB!G-|BWj4Wsm}Dli9f`JOwVWw3c+{PnHE6Yb{g{}>RO4a zNQ{g-iud3h?v(htv}E`qx;Jw8L_vzok(eDk1>|QrU_=1Qi~K~Bhv=BGOfZ&3jTKDJ z=#5ytk}6Tw`iP}IW@*Fc(!=e~MJ>-iNY~Z)ULU?W{QZ|=)tiOt&2Z9Q-A%q2E8w9o zYAHqr(fTBuRURQJCed=?`r}c{iIv*AH?6m<+tIvGkW&?|-m{dmmuSU(SNF}G`1awL$0vAv;X0xKrN<|~ zeMI!0zpx|f+49h&%iX6B=j;XbR$BAGLXC#aQaEQr#L*^dLWQ?IA9b91SfMsjS2g|L$EHzV}D$~Kn zWO9qPH=(Vc3}Febb+mkHa!W=~P;%mu&~u=y(j(0e2s0#1CML9;ZK~eaAY5+Cg2oY9 zbR}0vj>6GTx~Lu!5x)R309q?Dp`{2ap;`nN=TI#&&_4-91=^HyKV?WolP*0;waAb_ zdW@mM1kxj(2ZQjY;fHs6QfX-98_>j%BLO){b7;d6eBpz!l{(OJ5sHmcjK-PYgzS7d zyG1OXl{h_F$>5*z#H9K!luX(!Ne;~<4dX=h{4n1C#{`DXB4+{&Vn6q7Qwl9t^Id}it;<7826<67O?UHN#ZwZlI&3W&G|_D+zCn~~ zd|9QMHm#i#>UuA$j1N?8(^%!JwoN8>*ie>8|vs1MS3JE0ztA?;W#)dWs9BTH(TR*4{?QDWAbC8`N&3&uwN z9ugo%%DrkV3Z)gO{BkfEftrSgDMIF`pq&Qm0orpigG6%%WTC$1D9EHWlsxo-+-alc z8qQ}UP@rDJ<2kG5EmY@)Y2!6jF92UsuY}2uNij#6HgQFSgG=j&{NTe}sZ4rvTJANJ zT8b~2RU@jg%DEDhS4!n28A-~gj8rOEIqRev*IXCgX)|W24BCsdKFYB^Dpp-_l~T!? z^He3JLTPwG)tk?{SE{9QQuYd+4=I;wP6+6xTwKlV+Ex4GlWNlJPo0#C?~y4xUu%D@ zhf{6~l#A<=Mo?>R8l*Bc*K(tj`uKiJ-G5V-)Ds(wjfge_11ZbeDVOTCpHEp5r+iZg z?!8G+MFidO*Men1n#<&U zv>f?}P|#3mg4z}NQ4)hdb`&G}h-r?SnNyPC@uaDu?y#vMhKhQslyeweWkPnnsN>AM zUZU4Juc*|@)MGwPZ~qzfmL$FxbjJuS{xFxk+wT=a2nJ_26pTFh* z4%PCXyXF6!T22rH3(9$?53LvHNc790)n;;)gp-(DL@C|1shG}<&atDTk+aw1bv{TOvk3;eww{WLN8EfS8 z56%03uc5g^Lqi;B3{BmgV?S(QBl9_110{y|->Wr$p=wS2oiG2eNnBvBq0qQ~$KO&r zB_=Nuafce&)=BtZO5qhryXwpoOe@663Ry(vQ*8zRpHSB4>?R)~pz*m^<4=`N-ad3E z>$YeY$XJc=6GiTPL@jt`@6j%*j4}Rns35WAWEcXy5K&c+eUl7yTsF1r83dAkQ4-TQ zWIET-(zGFN02?mpn@ymKL@2Tokz^>Ms1(7ygb~RpmvpL1xPSz04D2&1qPjh`3XMBM zjq609CNe)gw0)1pjbPnH-H%VbtAB69yI=mBb3Z@#%dOFE&x!jP-D_>y5XzFrkNgXe zQzVJWM+{++D3;9z<7wcsGv*!f-%}msD4G{eO0A&x{}~0$c!1UqV^@RxCgPcI#{;+r zC$Ist+>l?iPCQLX-=^SA1TZw<&clF&k6W2zDRNe&^+8QyxbPIrQNRBY0(^?bmCwE$ zE)+L}T@4ra!zlIQfyBqi|7df}UM$#)!zJe<_Nj|Kzp*-hZE;*S&zi3|=Fi_NJrT8> zT$UaPr3a&yL*S&P8uoQBIhk!9I90D4C*R&8|7(6IlGC(kSagST)c$q86m|5jX!zHW zv@{CJm#Z7^m2|_-5IP#^Oh+S}zbWe8Eaxa=Id&%J*cf$pfRzWwI3``jZvBHoU1rwh zq1mBufp4L9p>T2Iow_^4cTc>R6WjkKVgHxH`+CE9FGe!@K5%+wpAF~iia5JJaJpxo z4ri~AIP39oOREFQ9`D^uquBc-doclO(RLvN7s5FW5l3Upu~Ber40jAh z9YYBwCqCUV*D>dtZwb4qmdoqz6>LGDof#OFOwX)8>}iN(G`@N6*15&=OHDf;(|y^t zl(n7oMD}0bFKH;ftB)2R_#mh7>eJz(W_(5E&ue~AbEhWKO6nq6g$di^WlsSHc+q#S z%S5I}f#0Pyl)|uXJ?8-jmHvg60f}6jx_H04YFN|H=4%M69 z!?R6>_uQsK#rF5|DE+;4rb9mKdzI{Yz3EW9^}QA|<=JF9tWRb^5^y3k=aXe@hP7Cb z|Dg7m|3lf79KM$*eo+WyqEThfDC-@d&xtEZFGD7Mw3wf8qnY?b1xm#S*Cg<)Wu2mS znW<=AYnEz5sqDigDAln>sj`Lw2(2}(EM+bB4B48JTyis$N__Mv7{FI3;BzhDt%aBi zUx036KHTV2(Eadv7Yz6sk4zHk53%jny!<#~(7a~EMIfXS5y3GFKTQDjMKOn1SSTYI zWngl@-`u|8<@+d`6`ukXGx9IuUCCx}E7C+B>jcFZkCmMrBrYOv-mKj!$|vrS1eVkx zo_zoQ4Z-w(f!7mibfWWLsL_=OmXer-%*d83RisAe$fD2aMjRyrl5~Eq%lYp0{##rFDA^Z+lJM zzU19XI2zlnuVG-;+O3~jV1VIDy%+;S-l1A*tTGeSp8D=8UcWT7F;*={78oe(l3s#X z?9x(qg?Ip7Sh(~Xh6~uK#3W^Lm0_%weVE$ywiYXv7*|PFY-9|aauI`zv%jpn?U1No zEgv_WGs!Ub3FRa)%CyE;Wr?NIN71lu!smZvwhvmCm}$+HDK=Qtm6S_uWwl0)?x4(8 zU86?X@0TV^Nw%f}q|2dyQtQDSlv}6e@R!Sd2`%7l5FVwxH zjQr<^2{Ln+!4|nYBVIWwN4Pxp{plK2ZCYy^_GB3Aa%o*?j;6fE(Do{+f~0xYs=tEf zSt=Kzsv7Cd)kjj5s#R(w&4*m7A?TAz#n+u>5hsMAry&$IPOCrsRww0De&IMEjjI9! zzWRbGDX&+_BgL3MsWh$zxocsir_7n~TiP$G9^j4yi zeU$Vf1zrlULP+OFW{r!l{u4@CjrFWd$KyQm|BNyNgo#4G%~J%GnQeFK!pBdA2VN3d zN26nWZ0z?0{I!1d6MW0ID^1L^aiJyRX;|@;e)|v%7t7ruca5=c?JCAo$T&6%PgF+o z_m$+Wi8+;a%i3e!T2Z0Q7dOKF^aTF}OdtEU+e9MelrQ0KjRqiz*(P)Aw<9HKIth1V z7d+Y-rMU7d(KFJo!fyH4VGKFw57~&n21i~{F6C=zG)zxH2hJNZc$>UxCA1WG zlC2^4W80~9l1Ab;*DfY)0~!&w4LT7?8aEnZc4>_ov^43oK#SzWP08E*m$A zF7ieHHXDI}?Uz4-ZD6JLzl60Gps`xZ~$79Ep_)9@$%vLD6D*XN%G z6p=Cw{3{rp&kYC4&}jaTse~>R?aLL_fHonnQf6fAO`=YDM!OOLD!M>nAk0p;AJH4k zu6pl#MC}yk_jFnMiHB&LFh~P_v z(5_2z6MlHfd1R%s`t?0G_k4d}tg=n0Y+HObQn~HY?x>>-pUk;*1nvT>)-MjeyGbZM zIdgcqqWR9=@UbrnMK8=8Qdo93+G9oaLQ#FV5FFRW<(#tDs$%6E1pMW+{Wi^5=zd_f zfQjc~OuQ>E%{R~oy}Jbb;p>ED-C<8pBxCnGgKrJKJMhy9S#xnYr)Z_Xm(2gGieyy3 ze(vVEa8q}*hCUW9Dmk*OmC0GFBN;Vsns1pGGo!UTA9m<6j*^cyeEo+pfamr1>}AUi z_rosm^D4vEiU)i2q!4MHwJy7|R$MtTSD65ft1bZsjPAT^byw?tmAmnQ)nv6jwCU{b z*_tc$5!<@gO*c*RV^MD#nj$KOUTMFP^J?Ms!udXt=U^aC;g>PYVg2h?7Vk72HCX?8hZ)cRCf#&2)66zo5-64VGUri+`s9F?K46G=f$}HN z#x55&XL4fBmFAxDBAyuXIKesg1t9=MZ54w-`U|o`u+*Id_Ds^hk@aKFZMjX z+ePIv%YY-kbn$>?u!bE_1d&8Y#PFt6=^H!AD5yYZ0z?b}Tx5B!9J^td>mp;*W?^0P zVvDe@Et20J$=dkx?w1d&7&Si-O0A#~`D7;o^++i_QASMljBpxuDbJ?#(0_+2=Kw4d zhVY%UQSa0Hfm)B9f!Q~hmyJh&6*C2I0(4BhGW8%}ou#0b`sG1{z@R&7^sE?jqQ<-x zqkGBdVZgs%tX&NMp*ykn@bEa;s-yT`N1`(WKGF`fR@W7o?^R*bn(W4^k>cw4??DZzh6j8r^F zg?X(M<8gqpDn97610@sf5g?}A{LhB@VuR`ED&>7c}_=S+-7Zw}HTWY>tyx09ZL zx0KHKA|=5B>De0j&1%dPFaou47FJW&>Tjx2!AncMI94vB%|A<9P>eJutStu4%G%9g-Ni9jrlNOi_mPsj!3OOhG(f$flek-I@TRWx zLyCdZ^8D&KrVTV9N}gVM#uXlqR3G?I>!p%a&l;p>BaKo_`Q_|N*(24;b16og7tV3p zDXqH1OlP3w&rPQVo>9HsEWM-r^1D}0SEZ6MS;}=*&sPT9rH87#8Z`wwqj!6TVD8STb{CRU95#KqJ>}LT&h~MR*#-Pq{^W&D*Tq! z(^aWjy?_BzaI-Y7s=OLCse8f3+Q4P8p8OGdGQ(x5-q!BPBrJg4umA>ka)ivWQZCi| zL<^-{QtiaAgi5fPxS?Ej2q!|y?kSaCtzA6aQ5}LK zDg_IR+t|b7!-GS?xIPru2SPT2-n+SaC`1hzjGa3mc)p7nOxMOB-tQ}0DCT<;@;D8VM`2hb~hs?)#bWx*8Q;ij{YxAKQjH#FC!{Q6gJer0h_blsMjmdjgaw}=JY&AHgKvd*_qaXS!Qx8aI`T!g-2zmt7&KZsn5 zedeWm=7JmkS4XaoEVTUT_=?5#O3t;ytA%rYS4)>H-es%fisPQO__dtZ3vU+Q_Qx7` z2#sVL+t_`ttUEO~O0;EP>Az%`S#F!*|)|Ptg+h7 zLha^g?UuXN_nwX&?icX4vwx+?8!KvoPuoSGP_&gS1M^obMNv!1ip71&L6T{ZtXB?5 zu42Na&0%-To%HuDJE%(M6}w<9y4ka0cg5_b_w1#wwZ|$p3KbiF?2m0fD&Vi;=!zrv zmC0CMosd^|yL-{_#=a#-JG5gepVEizrS~mVh~b-s*L&#m*qiQEZTj)kv29NY+n$O( z_d@KsVF7>JhS8W;+TjvP$gWqy~s1nNn8V)i7gP&Mmt3($$wFenTX;ZN|P_TsFUDG3(}z#Xh0D zLnz)vgI2Jd^Z)mD?J;egS^i$IF~&BriET_`z&wL_g*PM(Ng)`LKpe6mA(=d=6B}@b zKy)!}aUW{6m6~p|8rZfIqIMNB6K%*$q%xZBPG;Lx+ikj&HY3%yHZ^gX=}22?)IWAb zpett3O1tNL_i5(_(#~oP`tIXQwPTRI+NtkV+-1@z#24WF;$4C>A_=>((t|dV{yo z+XVTo{cVx8L}L1h&*Sz7q!H+gpD0TmTCe4+|IpsZVP6$j`_i^w&(nL zu!zh_FAVxTaBx)_Y>qot1>4-CpkCoiFwQO56F4p05DhHE@X-(YkoMW`XskN_bg-Bg zgosO%lY9^qGQgyF-55&DEnfNYdk7BW_Mh<`0^$o)fSjB@iX4P!bdd26{}Akq$BUuH z@$FTR8uDr$P5QZ2Cb5Ds6MjsMOtMDdODT)3Fy>{x3i~2q4MNxiC0?WyTP7bVmTxV~ zr7#zza3bGId?n(eBkC8U2JQ@v)H?7!Wr+QKVtr&Sps?o>RzRBa%W~}#>pLKSE3Tz+ zVUhJtegV-UpOmU150M~TV>Bic9KqTZ&Rvd5)#2R~I{VSV%>bwAgUm)5faqm>B&09F zM@6a^)NTkNdkXI5YByl~iij-qf!${~uk{5BPvb1=<7bWtsT3ay&pk%5Ai$DG9f^X^ zHx}Gf19kU#$u@$QTg{rQ-zkdLw6Zm=w_VY;BW&A|h`IL9ulByKju`XF0i=^P zIwQt1IOYQrZ<%FmW?4A1VnTJVvSzw&hW<(8EiHz1fWIj_YTU{iw?>TB^UgASTRdM- zM11E9i}9NGHIK!*=?y)13eM|6{T=jAuz+Yl@nFm6QMPHcf_vf=WDztdsCufRHXdOY zCkL5!*Pvfsre6lDYqDik>|zMT|Aa0EW5l#9q{1W=LMp*)NyJmaM^<67N~DxxN+N-@ z?x#$3QzU^D)dWOc`oBrKKwn4#U2F|N|I6DWU1$aAx@Ij!!X=@WMk&5<=hePKD+xT` zOcNFy#WIVS5OK9vN*@p*)=5jQ&_H?NPska>e%E=1vRj0jNLQh4fC)||emXCHUxT{o z?qD)V;RTHdINr_8YKPl%&KJ~*&~-t*;4usy49Eq5_>yR8_KZ|Qio#tInE-bn5HUKK zbXM-q-=iHB=0+UGzrv6Q4*Vse3zGC!k|buQB&o#POcgl?N_+v(MS(RQ3_>Bl0)E7* ztT-iQIOO6xr6D490(27UVJG|~f9d>8X&`!41dc=f_sQNA*i-R&!mtwu+gWo$((tHc z3q#5gZLF@{PH=8wTc=#0Ss*VDqOCjA+zb|r!4pB|AUZwZ1hrcnXBZ!Bjl7ci)1Xxt zE1n_n^Nu@wPy0tfe`OM4fXyY8edDB!vN0VUI!DnVpC|HV`V&5iY}Z&S&z1%_YQ{G- z;_<@i_caXi-{8d3a7gl4?%1(lp_G#g(t1bwxY7mdls{64<<5V{BuPmz*6aAux(+G+ z3DDhhogD7;l!&~YDI+y9p9iuvMCX0)?~v)0-1P;twh zv^o2suR(3E^Kxg@Uda+`Tl3j@*A8Dlb@ddRUCT!a6{Cev`vG7L=5V$WM4MNF*exPR z;%1YW39VrYvGq}|j#!%j6Pl#^h|r-^$F(`OYNC0}5{#ikhscBx^;XhL#4cts^DpQ{ zBEAQAoM|(`0H#jX%)z{Md*Vmf>`^GgpFuxfh{CqDbIdsc6 zSKh|sr-ib&hRsz`a}#TBy0sZ`)$$6i+pgMXha-8eXkI6q*LkNioOg6$-+!s}mTpA4 z-ekXGy=;Bc7R_?9S%Aj2cP?w+f^E|k?PcwB$?TC|o%qFxh^;kh+r!%S+_6S%-IC|A zh;3KY=3;HG56(qwJ)rBjsgqtQqAFbUrQ?gGlqvI)Zc_J#0aTyBBb2pomGcahx2;|} zK5=}NNZ(#6bfqM}+fv)MUHz|>EtzeVn%nABc(`py1&`a6>b6GR?OF}K*{*gaYj5w; z!26F=)UFNjYE$BJ`zf@$e6O+gG9VCzV3Zr!5wIkzxp1Omp zeiK$@F1VLykHfqkfP%=@T&xgxqJ?X2Oog_8I1E*?{Qfge=6AsXr9?fSQXj6eGV$10 zI_Dv*pAVx0;zM1>x&1oi2~&F`9I;$_e)9S7#^Ra0&o`Fwfcl{vSkmUq2j&4{KesTN z3!+!m6Z!>n)+P6(8;@%0*@F5I*kLYm-Yj~%@^>^1*<*sFyW06elV5A$wr%FZ$MdBH9G9B>V&HODUVkNTpT!A?#10o?EQ*|9^Y2$Dr zjBi;%uGO$WK)1s4aiacpb3Vdev2a> zIleDYH+$bXe5>=0>9fpFGQ$-IBZp3g5B0I-PjIxST2c=H4T^y8JhxFoT9i`>FcOel z7<2~#?#Uk*-~J7*o!@xD`TuL30&EA|*?+Ib2xDU)bAxM)(2o5a6i2wCH*)Mu_?V9^ z?qMpR^*6w~6A z?ZYE|CJ!5gjFay(M83dQv6EJL@AUgazaDybIJ)x)yYon>_h_{D6x(|$va^pHBsMPV z6pq`%8z~(Pj04Cg*_WZPJ&4~Ei^0r?SSlg|OE%j706B1bY_g9dTR71o4CBSXKfoYY z!HKB=C4G!}6JO$lAOI$iFhO(%O_qNGk61dt#`W>}y^ob^#tG=R&lv>tj1U-y(U(T` zHLSiSREwY@ODP)-+258@IgTsGE+1PknqNPB>D1(@sHKFpfJybzu(50bEUjBhSZm4r zrjl`uz5*uHRLQ+X~BMql^3fA`zen_^o>z{ zGph$x_!D9M$*A7P>V2X9(TF}E024RO3)cFt6XAEeaD)G2$|SR6H0vqoTv-GYbquPpY--l^kZi}UTG z8%3ciSEQ5}w86mHVdIvlv4%C)yic(1_|$ z|E*D|aRZ9$x3lH#5q$@5WoB!{RK-_yqW>CNV&^@aVN>bbjW-%YwMQaly<9!BCbOnf zW(FdrhNx)=Yua(^#GGj#?(6|2=Cxx{OEGIH4sF>zXAw5^Q+=Rq7B$zi=K4^BJ7PY7 z=5r7~xi5lRXvFT$yghrmdZv0Ir{K!S<&k^&Pe4~%tk9L2_DSE={)n}dj|9{~#9SrD z%9-9ha~fhrb86X~+I#u^@^~S8L&Vy+l1o`#2qc@)Nq<|&x57mKbQ@dO7tudKYC7>; z#8k9$04(F~A&|2HI;0(4aVJ5;v8KA!l^jeWWB~*el1#`1WPm>jLx&6<1eKP=SRF+u zh+h^S)FywJ*;d%ytp511-PUfK<`YxvW_Y?=Uf5l&`K_^aS9g*2vkj^6@L6URc!V^m z5GQ0)cjxOu*{S%ZNZnm&2$gCeOsHDjyzPej-P#REE#cc{o&6vZhLxOWOXO5v-ln2X4Dl!TI{qXE#tgg=R$GM-TT?~w zw%maDfm`gD4ioP4GM=YE8;a;rk)_vz3nf{Y8z5AI9DB^bEtkBJ0wxB5@8Nohn;mYw z%y}he${W!ZM73qC7NFLTgtfgp=Ye%@z&{~$Tp8CGQ;*Opl6;aFCzi$<+eHW98mu53y( zN0OaV8Y7~`rw2!A;;3EkdEAir*#f}eCzWLyF0MS}{lhFq?|Ml1-OBIB4cvR@rl5`c zV4m~%Ij9G;rQ`t?a%HfRmvT5?(cln2oWKkKyF?X(6{En22T(;q`g3aA@pg`Qa0p-o z)NzoNCNPXtT_vUjY=vM)0-%J4<8;Jj2w!UQ77U<**(L?VV@$}<)6&u5YIC&j?Q$L3 z9?a#XC^(BSTp7X0i^!f}kbIA+_M@epdHi&j!E-RvKa5-&2VvaEs8x4x!(n$^25?$| zG;c5+Q$X#-!^41{?2V;xtRkiyJs(u$ZwRKfba!_hb9A?KxH#^OmACrKGonaCI)(;< zdbwyxP|ro{=sM8a(iJog4e-GMn-(Qlf`*cAX5{z7ivmGR5t zZ$1;vtzmO(W}CvfL`a;sR1;_4r6!VE`+y4gz?@+affRvod1JFcl;wtvPL3!G)f|c# zdxZDJ)5U-;`Hm%8-NIJ4M2xLpS#l?Ta1AVghb?tH%3@u_Qoo|5GP1!8ClG$}bVFH4 zlSYlTtg$v!cQ|4^!YBNBR`cz{QCBbP>WvtW3W+`s7EYBDjiV+T=1h&44v6zll}Ern zsBt$Rt2Sb(`dtJ#-7F1+zw1zUI(2u8 z$oq12XTA1rwFcteZBlp98V?%ZP1^yhmj8TUXPS59%ov)DtAvv**OPG51q^c^@I*_t z@LDNb${qb+U|>v&Hf3 zvkqnd9@o*HmcMefY;l6#Y+#HTevBlDzz6}<)^RjLM7UN4M!H6TxY!Nc5+8$GoI-W5O1?@kF^4VC=Ii&24F!Zu zpG$iVvfr^)Xfd!PmICl5kW4JaKQKJP_+n`?nKn&TCHeDDz<~qsla_HMPz{cZ24br7 zAoj+cJ|y{(oyMTFm6LAgz{r!`?d{HO9NexCJeYsSs2b1c`0yG3NGv5V4hY!;4AL%) z3LX4TGf3seG{YcNH!$Ys7B<3KMlvU+>4yUq&v`$C{M1;AZy3OgeB8M-LTYl9S{vRG z@ZKl!?p1Vtgw9oPVye?4BSQ=kp86TSd>@^EL+1;0V(9!Hoj;;ObdID2aRXMe6rDPB z4xvMoyNQfxe%C zgH`jK23BFh3Y@wFn3!WjzMaf}Kq&a9Mzdijy_ihX^p}+OOG@`8W%!ax|B5OPQ{`V# z4wiEKjo~{2bbJ|8csUZlkB`mMOe0 zo}ksVdFs$21()fgGiR=zT*lkQbQSHG@-9ky-dMn$!e!fOFPo&nk5@~G}FcR>+-?Bc9DY1^wYD+ zSDz!dcg*AxI^DmFK8t(kM)2RbOu=P|p>ydSORe+{`ZT@tEL}w(r zOwAlsIZxrU{zd&?r$;FZOIadR<~>UHlHm_j3V5Wvm~!F7w02f?>+ly82jKs|8ZG%% literal 0 HcmV?d00001 diff --git a/gallery_app.py b/gallery_app.py index b8a82a1..a1bad49 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -1,5 +1,6 @@ import os import math +import shutil import asyncio from typing import Optional, List, Dict, Set from nicegui import ui, app, run @@ -32,6 +33,7 @@ class AppState: self.next_index = 1 self.hovered_image = None # Track currently hovered image for keyboard shortcuts self.category_hotkeys: Dict[str, str] = {} # Maps hotkey -> category name + self.hotkey_by_category: Dict[str, str] = {} # Reverse mapping: category -> hotkey # Undo Stack self.undo_stack: List[Dict] = [] # Stores last actions for undo @@ -42,17 +44,26 @@ class AppState: # Batch Settings self.batch_mode = "Copy" self.cleanup_mode = "Keep" + self.applying_global = False # Loading state for global apply # Data Caches self.all_images: List[str] = [] self.staged_data: Dict = {} self.green_dots: Set[int] = set() self.index_map: Dict[int, str] = {} + self._cached_tagged: Set[str] = set() # Cached set of tagged image paths + self._cached_untagged: Set[str] = set() # Cached set of untagged image paths + self._committed_files: Dict[str, Set[str]] = {} # category -> set of filenames on disk # UI Containers (populated later) self.sidebar_container = None self.grid_container = None self.pagination_container = None + # Sub-containers for partial refresh + self.number_grid_container = None + self.category_list_container = None + self.index_display_container = None + self.stats_container = None def load_active_profile(self): """Load paths from active profile.""" @@ -92,13 +103,14 @@ class AppState: return cats def get_filtered_images(self) -> List[str]: - """Get images based on current filter mode.""" + """Get images based on current filter mode using cached sets.""" if self.filter_mode == "all": return self.all_images elif self.filter_mode == "tagged": - return [img for img in self.all_images if img in self.staged_data] + # Use cached set for O(1) lookups + return [img for img in self.all_images if img in self._cached_tagged] elif self.filter_mode == "untagged": - return [img for img in self.all_images if img not in self.staged_data] + return [img for img in self.all_images if img in self._cached_untagged] return self.all_images @property @@ -116,9 +128,9 @@ class AppState: return filtered[start : start + self.page_size] def get_stats(self) -> Dict: - """Get image statistics for display.""" + """Get image statistics for display using cached counts.""" total = len(self.all_images) - tagged = len([img for img in self.all_images if img in self.staged_data]) + tagged = len(self._cached_tagged) return {"total": total, "tagged": tagged, "untagged": total - tagged} state = AppState() @@ -129,19 +141,31 @@ state = AppState() @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400, q: int = 50): - """Serve WebP thumbnail with dynamic quality.""" + """Serve WebP thumbnail with dynamic quality and caching.""" if not os.path.exists(path): return Response(status_code=404) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size) - return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) + if img_bytes: + return Response( + content=img_bytes, + media_type="image/webp", + headers={"Cache-Control": "max-age=86400, immutable"} + ) + return Response(status_code=500) @app.get('/full_res') async def get_full_res(path: str): - """Serve full resolution image.""" + """Serve full resolution image with caching.""" if not os.path.exists(path): return Response(status_code=404) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) - return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) + if img_bytes: + return Response( + content=img_bytes, + media_type="image/webp", + headers={"Cache-Control": "max-age=86400, immutable"} + ) + return Response(status_code=500) # ========================================== # CORE LOGIC @@ -161,50 +185,69 @@ def load_images(): # Clear staging area when loading a new folder SorterEngine.clear_staging_area() - + + # Clear committed files cache for all categories (new folder = new output dir) + state._committed_files.clear() + state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) - + # Restore previously saved tags for this folder and profile restored = SorterEngine.restore_folder_tags(state.source_dir, state.all_images, state.profile_name) if restored > 0: ui.notify(f"Restored {restored} tags from previous session", type='info') - + # Reset page if out of bounds if state.page >= state.total_pages: state.page = 0 - - refresh_staged_info() + + refresh_staged_info(full_scan=True) refresh_ui() -def refresh_staged_info(): - """Update staged data and index maps.""" +def refresh_staged_info(full_scan: bool = False): + """Update staged data and index maps. + + Args: + full_scan: If True, rescan disk for committed files. Otherwise use cache. + """ state.staged_data = SorterEngine.get_staged_data() - + staged_keys = set(state.staged_data.keys()) + + # Update cached tagged/untagged sets + state._cached_tagged = staged_keys + all_set = set(state.all_images) + state._cached_untagged = all_set - staged_keys + # Update green dots (pages with staged images) state.green_dots.clear() - staged_keys = set(state.staged_data.keys()) for idx, img_path in enumerate(state.all_images): if img_path in staged_keys: state.green_dots.add(idx // state.page_size) - + # Build index map for active category state.index_map.clear() - + # Add staged images for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: idx = _extract_index(info['name']) if idx is not None: state.index_map[idx] = orig_path - - # Add committed images from disk + + # Add committed images from disk (use cache unless full_scan requested) cat_path = os.path.join(state.output_dir, state.active_cat) - if os.path.exists(cat_path): - for filename in os.listdir(cat_path): - if filename.startswith(state.active_cat): - idx = _extract_index(filename) - if idx is not None and idx not in state.index_map: - state.index_map[idx] = os.path.join(cat_path, filename) + if full_scan or state.active_cat not in state._committed_files: + # Scan disk and cache the results + state._committed_files[state.active_cat] = set() + if os.path.exists(cat_path): + for filename in os.listdir(cat_path): + if filename.startswith(state.active_cat): + state._committed_files[state.active_cat].add(filename) + + # Build index map from cached committed files + for filename in state._committed_files.get(state.active_cat, set()): + idx = _extract_index(filename) + if idx is not None and idx not in state.index_map: + state.index_map[idx] = os.path.join(cat_path, filename) def _extract_index(filename: str) -> Optional[int]: """Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42).""" @@ -213,6 +256,20 @@ def _extract_index(filename: str) -> Optional[int]: except (ValueError, IndexError): return None +def _add_to_undo_stack(entry: Dict): + """Add entry to undo stack with size limit.""" + state.undo_stack.append(entry) + if len(state.undo_stack) > 50: + state.undo_stack.pop(0) + +def _remove_hotkey_for_category(category: str): + """Remove any hotkey assigned to the given category.""" + to_remove = [hk for hk, c in state.category_hotkeys.items() if c == category] + for hk in to_remove: + del state.category_hotkeys[hk] + if hasattr(state, 'hotkey_by_category'): + state.hotkey_by_category.pop(category, None) + # ========================================== # ACTIONS # ========================================== @@ -232,54 +289,48 @@ def action_tag(img_path: str, manual_idx: Optional[int] = None): name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}" # Save to undo stack - state.undo_stack.append({ + _add_to_undo_stack({ "action": "tag", "path": img_path, "category": state.active_cat, "name": name, "index": idx }) - if len(state.undo_stack) > 50: # Limit undo history - state.undo_stack.pop(0) - + SorterEngine.stage_image(img_path, state.active_cat, name) - + # Only auto-increment if we used the default next_index (not manual) if manual_idx is None: state.next_index = idx + 1 - + refresh_staged_info() - refresh_ui() + refresh_ui_minimal() def action_untag(img_path: str): """Remove staging from an image.""" # Save to undo stack if img_path in state.staged_data: info = state.staged_data[img_path] - state.undo_stack.append({ + _add_to_undo_stack({ "action": "untag", "path": img_path, "category": info['cat'], "name": info['name'], "index": _extract_index(info['name']) }) - if len(state.undo_stack) > 50: - state.undo_stack.pop(0) - + SorterEngine.clear_staged_item(img_path) refresh_staged_info() - refresh_ui() + refresh_ui_minimal() def action_delete(img_path: str): """Delete image to trash.""" # Save to undo stack - state.undo_stack.append({ + _add_to_undo_stack({ "action": "delete", "path": img_path }) - if len(state.undo_stack) > 50: - state.undo_stack.pop(0) - + SorterEngine.delete_to_trash(img_path) load_images() @@ -305,7 +356,6 @@ def action_undo(): # Undo delete = restore from trash trash_path = os.path.join(os.path.dirname(last["path"]), "_DELETED", os.path.basename(last["path"])) if os.path.exists(trash_path): - import shutil shutil.move(trash_path, last["path"]) ui.notify(f"Restored: {os.path.basename(last['path'])}", type='info') else: @@ -339,17 +389,25 @@ def action_apply_page(): async def action_apply_global(): """Apply all staged changes globally.""" + if state.applying_global: + ui.notify("Global apply already in progress", type='warning') + return + + state.applying_global = True ui.notify("Starting global apply... This may take a while.", type='info') - await run.io_bound( - SorterEngine.commit_global, - state.output_dir, - state.cleanup_mode, - state.batch_mode, - state.source_dir, - state.profile_name - ) - load_images() - ui.notify("Global apply complete!", type='positive') + try: + await run.io_bound( + SorterEngine.commit_global, + state.output_dir, + state.cleanup_mode, + state.batch_mode, + state.source_dir, + state.profile_name + ) + load_images() + ui.notify("Global apply complete!", type='positive') + finally: + state.applying_global = False # ========================================== # UI COMPONENTS @@ -393,13 +451,9 @@ def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool = def open_hotkey_dialog(category: str): """Open dialog to set/change hotkey for a category.""" - # Find current hotkey if any - current_hotkey = None - for hk, cat in state.category_hotkeys.items(): - if cat == category: - current_hotkey = hk - break - + # Use reverse mapping for O(1) lookup + current_hotkey = state.hotkey_by_category.get(category) + with ui.dialog() as dialog, ui.card().classes('p-4 bg-gray-800'): ui.label(f'Set Hotkey for "{category}"').classes('font-bold text-white mb-2') @@ -417,24 +471,25 @@ def open_hotkey_dialog(category: str): key = hotkey_input.value.lower().strip() if key and len(key) == 1 and key.isalpha(): # Remove old hotkey for this category - to_remove = [hk for hk, c in state.category_hotkeys.items() if c == category] - for hk in to_remove: - del state.category_hotkeys[hk] - + _remove_hotkey_for_category(category) + # Remove if another category had this hotkey if key in state.category_hotkeys: + old_cat = state.category_hotkeys[key] del state.category_hotkeys[key] - + if hasattr(state, 'hotkey_by_category'): + state.hotkey_by_category.pop(old_cat, None) + # Set new hotkey state.category_hotkeys[key] = category + if hasattr(state, 'hotkey_by_category'): + state.hotkey_by_category[category] = key ui.notify(f'Hotkey "{key.upper()}" set for {category}', type='positive') dialog.close() render_sidebar() elif key == '': # Clear hotkey - to_remove = [hk for hk, c in state.category_hotkeys.items() if c == category] - for hk in to_remove: - del state.category_hotkeys[hk] + _remove_hotkey_for_category(category) ui.notify(f'Hotkey cleared for {category}', type='info') dialog.close() render_sidebar() @@ -451,74 +506,69 @@ def open_hotkey_dialog(category: str): dialog.open() -def render_sidebar(): - """Render category management sidebar.""" - state.sidebar_container.clear() - - with state.sidebar_container: - ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - - # Number grid (1-25) +def render_number_grid(): + """Render the 1-25 number grid for quick index selection.""" + if state.number_grid_container: + state.number_grid_container.clear() + else: + return + + with state.number_grid_container: with ui.grid(columns=5).classes('gap-1 mb-4 w-full'): for i in range(1, 26): is_used = i in state.index_map color = 'green' if is_used else 'grey-9' - + def make_click_handler(num: int): def handler(): if num in state.index_map: - # Number is used - open preview img_path = state.index_map[num] is_staged = img_path in state.staged_data open_zoom_dialog( - img_path, + img_path, f"{state.active_cat} #{num}", show_untag=is_staged, show_jump=True ) else: - # Number is free - set as next index state.next_index = num - render_sidebar() + render_number_grid() return handler - + ui.button(str(i), on_click=make_click_handler(i)) \ .props(f'color={color} size=sm flat') \ .classes('w-full border border-gray-800') - - # Category Manager (expanded) - ui.label("📂 Categories").classes('text-sm font-bold text-gray-400 mt-2') - + +def render_category_list(): + """Render the list of categories with hotkey buttons.""" + if state.category_list_container: + state.category_list_container.clear() + else: + return + + with state.category_list_container: categories = state.get_categories() - - # Category list with hotkey buttons + for cat in categories: is_active = cat == state.active_cat - hotkey = None - # Find if this category has a hotkey - for hk, cat_name in state.category_hotkeys.items(): - if cat_name == cat: - hotkey = hk - break - + hotkey = state.hotkey_by_category.get(cat) + with ui.row().classes('w-full items-center no-wrap gap-1'): - # Category button ui.button( cat, on_click=lambda c=cat: ( setattr(state, 'active_cat', c), - refresh_staged_info(), + refresh_staged_info(full_scan=(c not in state._committed_files)), render_sidebar() ) ).props(f'{"" if is_active else "flat"} color={"green" if is_active else "grey"} dense') \ .classes('flex-grow text-left') - - # Hotkey badge/button + def make_hotkey_handler(category): def handler(): open_hotkey_dialog(category) return handler - + if hotkey: ui.button(hotkey.upper(), on_click=make_hotkey_handler(cat)) \ .props('flat dense color=blue size=sm').classes('w-8') @@ -526,48 +576,61 @@ def render_sidebar(): ui.button('+', on_click=make_hotkey_handler(cat)) \ .props('flat dense color=grey size=sm').classes('w-8') \ .tooltip('Set hotkey') - + # Add new category with ui.row().classes('w-full items-center no-wrap mt-2'): new_cat_input = ui.input(placeholder='New category...') \ .props('dense outlined dark').classes('flex-grow') - + def add_category(): if new_cat_input.value: SorterEngine.add_category(new_cat_input.value, state.profile_name) state.active_cat = new_cat_input.value refresh_staged_info() render_sidebar() - + ui.button(icon='add', on_click=add_category).props('flat color=green') - + # Delete category with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): def delete_category(): - # Also remove any hotkey for this category - to_remove = [hk for hk, c in state.category_hotkeys.items() if c == state.active_cat] - for hk in to_remove: - del state.category_hotkeys[hk] + _remove_hotkey_for_category(state.active_cat) SorterEngine.delete_category(state.active_cat, state.profile_name) refresh_staged_info() render_sidebar() - + ui.button('DELETE CATEGORY', color='red', on_click=delete_category).classes('w-full') - + +def render_sidebar(): + """Render category management sidebar.""" + state.sidebar_container.clear() + + with state.sidebar_container: + ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') + + # Number grid container + state.number_grid_container = ui.column().classes('w-full') + render_number_grid() + + # Category Manager + ui.label("📂 Categories").classes('text-sm font-bold text-gray-400 mt-2') + state.category_list_container = ui.column().classes('w-full') + render_category_list() + ui.separator().classes('my-4 bg-gray-700') - + # Index counter - with ui.row().classes('w-full items-end no-wrap'): + state.index_display_container = ui.row().classes('w-full items-end no-wrap') + with state.index_display_container: ui.number(label="Next Index", min=1, precision=0) \ .bind_value(state, 'next_index') \ .classes('flex-grow').props('dark outlined') - + def reset_index(): state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1 - render_sidebar() - + ui.button('🔄', on_click=reset_index).props('flat color=white') - + # Keyboard shortcuts help ui.separator().classes('my-4 bg-gray-700') with ui.expansion('⌨️ Keyboard Shortcuts', icon='keyboard').classes('w-full text-gray-400'): @@ -653,19 +716,29 @@ def render_image_card(img_path: str): on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) ).classes('w-2/3').props('color=green dense') +def render_stats(): + """Render only the stats labels (tagged/untagged counts).""" + if state.stats_container: + state.stats_container.clear() + else: + return + + stats = state.get_stats() + with state.stats_container: + ui.label(f"📁 {stats['total']} images").classes('text-gray-400') + ui.label(f"🏷️ {stats['tagged']} tagged").classes('text-green-400') + ui.label(f"⬜ {stats['untagged']} untagged").classes('text-gray-500') + def render_pagination(): """Render pagination controls.""" state.pagination_container.clear() - - stats = state.get_stats() - + with state.pagination_container: # Stats bar with ui.row().classes('w-full justify-center items-center gap-4 mb-2'): - ui.label(f"📁 {stats['total']} images").classes('text-gray-400') - ui.label(f"🏷️ {stats['tagged']} tagged").classes('text-green-400') - ui.label(f"⬜ {stats['untagged']} untagged").classes('text-gray-500') - + state.stats_container = ui.row().classes('gap-4') + render_stats() + # Filter toggle filter_colors = {"all": "grey", "tagged": "green", "untagged": "orange"} filter_icons = {"all": "filter_list", "tagged": "label", "untagged": "label_off"} @@ -678,13 +751,13 @@ def render_pagination(): refresh_ui() ) ).props(f'flat color={filter_colors[state.filter_mode]}').classes('ml-4') - + # Save button ui.button( icon='save', on_click=action_save_tags ).props('flat color=blue').tooltip('Save tags (Ctrl+S)') - + # Undo button ui.button( icon='undo', @@ -738,6 +811,12 @@ def refresh_ui(): render_pagination() render_gallery() +def refresh_ui_minimal(): + """Minimal refresh after tag/untag - only stats, number grid, and gallery.""" + render_stats() + render_number_grid() + render_gallery() + def handle_keyboard(e): """Handle keyboard navigation and shortcuts (fallback).""" if not e.action.keydown: @@ -791,46 +870,6 @@ def handle_keyboard(e): refresh_ui() ui.notify(f"Filter: {state.filter_mode}", type='info') -def process_key(key: str, ctrl: bool): - """Process keyboard input from JS event.""" - # Navigation - if key == 'arrowleft' and state.page > 0: - set_page(state.page - 1) - elif key == 'arrowright' and state.page < state.total_pages - 1: - set_page(state.page + 1) - # Undo - elif key == 'z' and ctrl: - action_undo() - # Save - elif key == 's' and ctrl: - action_save_tags() - # Custom category hotkeys - elif not ctrl and len(key) == 1 and key.isalpha() and key in state.category_hotkeys: - state.active_cat = state.category_hotkeys[key] - refresh_staged_info() - refresh_ui() - ui.notify(f"Category: {state.active_cat}", type='info') - # Tag with number - elif key in '123456789' and not ctrl: - if state.hovered_image and state.hovered_image not in state.staged_data: - action_tag(state.hovered_image, int(key)) - # Tag with next index - elif key == '0' and not ctrl: - if state.hovered_image and state.hovered_image not in state.staged_data: - action_tag(state.hovered_image) - # Untag (only if 'u' not assigned to category) - elif key == 'u' and not ctrl and 'u' not in state.category_hotkeys: - if state.hovered_image and state.hovered_image in state.staged_data: - action_untag(state.hovered_image) - # Filter (only if 'f' not assigned to category) - elif key == 'f' and not ctrl and 'f' not in state.category_hotkeys: - modes = ["all", "untagged", "tagged"] - current_idx = modes.index(state.filter_mode) - state.filter_mode = modes[(current_idx + 1) % 3] - state.page = 0 - refresh_ui() - ui.notify(f"Filter: {state.filter_mode}", type='info') - # ========================================== # MAIN LAYOUT # ========================================== @@ -990,17 +1029,13 @@ build_header() build_sidebar() build_main_content() -# JavaScript keyboard handler for Firefox compatibility +# Prevent browser defaults for keyboard shortcuts (e.g., Ctrl+S save dialog) ui.add_body_html('''