f00e*l5C8%|00;nq2Z?}-AOm)M79jcW
z`1#+?{HKk11N{#^KmZ5;0U!VbfB+Bx0zd!=00AHX1c1N;Lx8fAgwswDB;m5-^Z)G3
z>o(?f=8XrYf>04400e*l5C8%|00;m9AOHk_01yBIK)^u}PQq>mV9}Qsl5R`4)shkSnV}4{Cr7JdOf%$7c$Bg*D
z?qhu4^KN)w_I%7c;hym{oF8}ngswP3)K|%yw6M?5>%
z6SkYkm))w^>UA0-I>11?JeMo;nF`NVGTAkrH3;sa0HktnWw6t37cR0x^~&=M*WzsmWfeYAtxEQqs)&+ey_k0wnnQi-s(QpMCU_Pg?_Is-B%41t8v3k
z=;)b%+#*-B7C8_)C~K)jl*_ZnT0{|GTJq&ECg`Y%W__5@1We0kmQ{_8anN+`4x5&b
z5t{}XGoyA-W~S6rW~ST(H_kYMGc&|>iI{@AD?Zg1TQzauHeez*Ae?Rq`{FFJjNZOJ
zMh61{;<hl^pK%#?FiGv?mh-D`Jx
zdqS(OO0jmW-E4QN*rt0R)HFj~)NS^6jRuWwwI=k$-FD}Y&6Y}Qe5Pm`GmToiWz?@X
zy2Ac;W4AB#8ts;$Zw!u*0Z&lBkX*y%54L|*5~0DP`8~@@dhY1$mD4nCt~>9N*w&G&
zy~e&+-D!x;dUxP<%tU-ZhgS=!=2a?}x9?&Xg8qsp99%5`23r}qE>@==u)E$j6j$med
zSO}retj3j#(c4$NbTASj?quW{0Yy#nER5p|`O0T%4?P`sW#ckp!mdr>mJEk-pFHR^
z_Jz*jq@`I`M5|C8iB&1aF0Uus?GEY$mP?ljGj@2E)x0}4iRuz#xS^#Q7bgTe>2m~E
zBg0_psN~Q?#_9inn+}G<#L*KH@tb0&ix!Hm`qN{gt)^nu(yXu6+yq-*
SQ{Cld
z!yTRTID)zGFt61jt8w+pdo<#dmJP+_gO0c(qVN(0N`_09$#H|=ZhU`ivt*7pEFDj|
z9KqqCo}tKU+;HPQdY?n;ZalHCVE2+Mw(kAEi^pkxb+Kr`US_#&sm!qb}LXlQo
z+!6ZCo@H;n`^Z6U&Ln+OC)DeL8dR&f^Jt!wX5ia$B%n+x$`8s!Lz{4h&cH+G*nS^H
zR#ri)sd<%WCDTNPN4A=PbHOv)$VoGs;$=0VTsUIX-KfVE^^qelA$5|Yn$$`35wBm_
zO<44F+)1=M%j#mOrNG2N%LC6W_LCY_)9hB``Zb9joYB#MHXDBb?{a?9=KmYtuXrat
z|LFdj`}bX+af@KzBxx*C!mDQ>?mu4~s}1=68S|LL${&5&X%ie^QN
zv|IPy{XXibcn!MD&X=*HyZ8Nm-}}DL`}MtdGT-KtYmJ`B?zB7mLXS<_Mr{OP`&pK?
z*=%w2_X7H>e*EZyQg6^t!f-w8GH$#4-slKcW`2luE-*i2M*Ls(F}`nmH@q);KJ1-v
z&v+WnkGOtBR~#Yg1@bECAYQP4+4eEwL(C84Y>#?DI>>RvvvWORyD9dB-KyB?bsA#V
zaCt6Q<}($Ztz@!mJZliJ({2~OZPeL9vBIzNWwuzVu*I#lwOQ8f!Z{rd6K<3X>zVR3
z_6hzPJ1u82%c>SpY@RQb`NC=uYbXj)w#?`GGGEN`o7j&oJKd-s)73IDYAdyp4BXMw
zIY+R-5w=FFF5c=s-9+tF75crlbYC^puo^eqgpQsG$h59#v<`$0%37kOT%J8fOA%mN
z^5rllwY^5OK1^r=rsXrssz%2+Xgc?XP0PoKO@oY>oqa84NR(|xg369;YsCUOJ9>6WlB&LYd`?VDqCFc2W_KB=|>#@{_-vX&ipyDYjUYb2T3
zs+0;vWV+55E4bI;V%VoL<=kT#b8qhMwL85%p;cF)MsnxQW0Hv79q
zgGRSn6MEuqyK~59OQkhFQ#6ekB;RY)>tf4VT5GqAxz!t8VSl@^+ZTF`cFWLn2FK5U
zpQzVJj$`izdq-6gp`oSKYL=Ds+|k=Br)k`ncOI3<*HJ}!jeW7Y(-52W?!fJssXYdS
zcr4ryntc;7i?+h`=p7!2)$8J+T5%IEb+B7bKnLw^1B0;?8he4&G@=%3;v@N7#Ugfk
z68SWFY}Ba^nas?y&+VmC(ll}>Eq7q?R!>3L(=Sh(@EHVeZgh{2(!pqyxKonQZMWM^
zQD`aj%y*|u0L?O;q2(B~Ue44G@!<5stA08-K2F@FC74PlRemrgd|CoKf*8-Dx-L
zV#hEAnTF|s(A%@*w$o^e!>8Q-&Q9Z&r5&D6spb>D6709@4KyiP
zNCY&unv-8^9?&Xg8qsp99)N5W3r}wG>@==u)E$j6j$medSb(9>tj3j#(c4$NbTASj
z?quW{0mW4EER3TM`O0T%4?P`sW#b}a!mdr>mJEk-pFHR^_Jz*jq@`I`M5|C8iB&1a
zF0Uus?GEY$mP?ljGj@2E)x0}4iRuz#FruXz7bgTe>2m~EBg0_psN}#z#_4~Ln+}G<
z#E~3C+z>lmv`}=_pB@u!H5Id#W_`8hCfM?NW)%pV>Mk!E?&zGy5zK{$d94;%jjLDQ
zqY3GU@QXpt3vKlwsxR2iHkox$u
zq}0dtl~li?P0s7-C;L9M8%xWy63R_4M@@}|;;g#3BlMd+%ienLk%QWtN&2KtsMiHG
zD2{ui7{YlpPf9cJZ8;K9CKcreWul=?I74UPp>u4%k0L9pkk-_^%9E67BEutFO~AR}
znQi2xnN9JsnouqrG3s8_ITguk0o)dOGeT+MQ)}vD8vv;-KY$
zXBPWOjjCyOt8x9Bl%K4}M}6qR!Hn9NuQ7kj{3`QKCXR040|bBo5C8%|00;m9AOHk_
z01yBIK;Qu5eU;yXXj+OzH}|9pSKB6Plu~wW=n?
zC^C)qOw_zY0lk`w_1Z15t1I(3k*(lFjwE9-UA?;})Qy^((*2a3oI|g{)jSV`Zcl6)
z^4pLTE)irLzZF)hf!;mh&Ct4D?xvFBM(ycfp0P0>XMT-&hWSV4dFERWcoReQfB+Bx
z0zd!=00AHX1b_e#00KY&2>c%sI88-|MTA8urc3czQBBvd5*cxY%sITnEzY;=l#Fq-}i6&)Bcd}zkL7X
z`!nDBeAj)ez9}E!{hIgF$Ot|_00;m9AOHk_01yBIKmZ5;f!9ZXJ?{vIC!9pCw2sd^
z+RU~0_t6;?-3ouTQe7()`HO5w)7U6&@@1rfz6)RMXd2<^bB?Jgl*j58zgekf%9X-q
zWwnnE3}Z8$UZdO7G%rjB9TO8sb7L#JR@i)uFK_g>n~mf%Bt|U=Vq+b^anJ4(7M57{H7h5U{U({_tv0hp!Rlb>(ywRn>dRg}Q{ApZxXwa?v^YfEHEJ
z6Lcc~6-8X6DsWHIawF$TYipTGp;XMZn@#CZE3JyIkOP51#^{JSezVx_DbTW|t0Pk*
z4vxe5@|(GGVFPEdB6e$?#sNP5Ny-ALQD-T~*cigMRbE4Xw4BKcq}(8KXCCOr_Hnx;
zb)loe9xIhIeboE(;SeHlYq^}2LS>DwGf00iEg1iWNe+Z8&m+(;Whp;2hfmFDHj=(cTPs7L}{Od!HLqQh~G&Dq%9C1{-Qs;
zhfvjaJiLA~A}2}v8Xm0}bqB+Z>m%7H8x<~`8%}Uy@3jpG8W$&RH8>cOGwv*m(Bv52
zLZGm3@|FQ9T}t}{b|)EhNxK2~{Xfb4p^fk0^s6wNm7klu{0rT7YjT#IynJi_K>8^N;^e!|+R2$f
zVYA9#&8=;%@RD+`(--{&-g?MZpW@3nlU@A&pJcvdW4_CLiTTDGTtO%v2mk>f00e*l
z5C8%|00;m9AOHk_01$XB0{Hg~%)0~R7&$`>Yy#MQWW=R!0O0rkcIG7;^9uSOe1HHD
z00KY&2mk>f00e*l5C8%|00;nq`yd%*!_B
zW#)VLaRtG3AOHk_01yBIKmZ5;0U!VbfB+Bx0zd#0@aQ`Lb~ichQZ@ka`~OGC|F)rj
z{r^#>$2`WI_kYL#IrPo{2mZYOobOw{&-#AdC;FDWKlXmX`+n~`yqxD%&lf!(@YFm@
z9@704_wT!Z$^C?zbG__(-u0a8U9N=lht7X+{-$%sIYYlne~JD8{Vsai@lD4k9Zx$J
zM}9o=`H}aJTpfu}FHwI$^{6x|8a_Y(2)rQ#(vQ%QXk>ic)e_sS*v-90PwWZ1-Cn=m
zXvc6`5&dP~j?=k#68)Fr@`-#rzjWno^i*_8HQX0=(HHaz7N+Q%a!rj@rFl7Nfz^V!5A
zm(4EZ^YhEgM$<;C-q>whlv$7jTs(zL7r3P~GEJqHjLvR1`&MOUQ8G8b9G{QpxMX%I
zou5yojb^n&0Z}*xHiweA`D}a{o2{&5SF))^qghWBniggil#EMTj;|oFE2*XBbjsL{
zyB!fjKUUl-PRE9e@p*0`wX(RdoLVrJ*KF)t)ou+XbMY){!yLDq;E~yK(#Z2rY&P3B
zEegCQ3AjWyo}162R$N(3uA~!2SB3p;v14JG!RcHgg_Z6#+U;g+w<8?tR-(}9?ZtZSme|c9DZ7fK9I9`6
zaVZs3%x(&u{o!VBM^f2rF_B88=3}#TFdamt9f(40&$6TqEaCF;+~RU>ZiWt}Lr8tQ
z*>2ShC=H%vB@xHxbBn0eAEvo9hYXsH-MwCHyVJgD=w$}0H7sYdSk7hnR5q~^iz==S
zH=CnJ5LQ*B-KLZIGSyirYGrO3|ANpmyV%#b@$NMfOoSvkz39uGMV&J
zguWEJr1(;83O7X5=!T&uTS(5VDMBDh6a*L@1|8STN#c*d8`rG}Eq1(x-
z87Y-ROAH#cmoL!aSQsUbh7+!Z45h7GdP${K*{V--}vU&ZGl@_WcKA+DoTslh!=5Vv_H(I@86<9}!+~QI`
zpIt}=Xl_m)_XDeo$sc)zS&HZL>BZz3Q!UJEi^)n_UvkUHD3czer{<<4tay$YTv<$J
z%IcEO^YL^tb=u_Qz`A0xlh+nhvrHP-~FZ=27_&9Er!I-Ms6Iy5_4#ly@2Lq9lrYi?da>O+2QM3nxB~@FAmE<64+O2qD
z?s%%M)a&H9XxgP{nnNO0Q;LY>P-xnzXb#LI3I=H!k)xl7X*w9kV{xEss+r2YQVw{g
z9ZIg|{-~--T~Us4W=9kPgPl;-)%qU}YaXT)9|n4!s;qQ6DUz8bm7E8=o2snzG&y)#
zu+xz^o}R4Y7S+NKw#f0yLj*k*ACs;0!EI1iLKOV({~vLF!p6MM`<&P2`4{(B{on9}
zy?^9x`JeE=>UqTTS?^WvxaW77g#WWXm+xiYwEO#>U-mug`?%lZ{d?c%z5l_K{JXwA
z@6Wit>&m!(-u2tAN!MqbpLdTsUviP|vU|?;i_TBDzT&;f{GR(W{`dMn%DlvU&VSig
zV9vUq^K3KM-;f6%C=>_)0U!VbfB+D<4+w;*NR$}1OZmtIHKj<8h0WOJZ5OBsMQ#q0
zvGB@8ic^IHkues(RTmG2!dSelXKjvsv2aNjn!{ZzOzT2(go}lXdVL3iS}aZJrsgmf
z3m0^U2P0Q3PU<-igsNDY(2WO!Q!I{8QZouGt7sIf%#TxHT}2N)v22c`Cep;on_-tF
zos)OIViQy_O$^`Wnq{d_nmA!!Yi5e#(uBP4GkiWe&;J!RAq=BT}{lEu#u4$#k$$R4EyuB5}fMK2|mC
zA!;n{LK^yXtcvjaf7|GN5CDLSKmZ5;0U!VbfB+Bx0zd!=00AHX1c1O#OaQ-lRy9n00AHX
z1b_e#00KY&2mk>f00dqa0sQ<=G9R=tUt>PV{MGBq3XFjO5C8%|00;m9AOHk_01yBI
zKmZ5;f&UeOhY7bWY>S7SPMa+++-P@92Xm|6-7lXdFlKk#CQ$A!q`Qs|QDrOWf00e*l5C8%|00;m9AOHk_01$X!2vByC
zaM>w>B%J7X2k`U%EBN{Ul?SGRP!S*i1b_e#00KY&2mk>f00e*l5C8%|z(El%!fqd-
z2&c;q-~R_40tf&BAOHk_01yBIKmZ5;0U!VbfWU)K0KfmYjXwDGgc?AOHk_01yBI
zKmZ5;0U!VbfB+D9GZVn?|3{d=v@x$R|H^!w`8#w8A0Pk(fB+Bx0zd!=00AHX1b_e#
z00KbZ{|^D*2xXgasmB9!eU$OG?jk8$fWQX=*nM`&7M3pY*#LGALD|Ng%BcW8<_ES>
zx?*D%n7{UO%!vQ1KF0TL?}qn9&xgGe?io+R`4QKT=!zpmy+B?i9mEUvFWWvwd|9USZlcn5t75CyX^7~M0`2l#uFPjDJX^_R*Lc<-V5i+KeA}qAg<^$Y
z<;!fbRAGx-YiqNt^s5QDke_B5F6`~$5C>cv<-&TVe2smAzs63>j?S{GMHIQ{%9qM~
zVYP@g6on{T=JR}+FXs47T-Po;-KZba)iN<^E94{tca-_T$e$ND!q#Zj#arE{o9GOP
zs?hJXrTeOZVl{5K2^~EXkXz)6)*=T&2W2g_h;n)MSc@nE+#>SjFed0QiDrG6&;(4&
zXO>lsj&aa*?hTukj}e;&88f5qp3F?Cr_4;b32vNm1ZQT5YZ5U9bys}4FScspz-_=p
zZa_HQ686PeWEs7EbBqoK0>s@X)mFgxyJt++vg2--MfYcoBr{u;QlW@U*ZE=v_c~k*
z`&6c!dn{ubF|O`jyVKhfT6I;5wQKEWyHmwB-2xn)ry;Vse|2Wl5-ujyA2G+QfTZ2R?~=Dtcj20a}|r&=}F|%V82~&ph?L>BA~g|ocvnzfL1Zn
zh?Z0J0A#CJcyfzpr*TE2?r4m01T*8q0t|&_HLhHY-oE0cgOLbvCnL`YC~lKyVH|zP
zS3XmF=;^pC8y6W9c5Mo`WH^-j#H?4!IsxEi(uOv>&nZ9J38la1askGUaLh`($LciIw?5+16IjB^U^hupiuM28W9QQ~ug!5>g
zlxE=DawMQkD#{PaL_?c!hR(o4=h%K9MOIcJt*LpHCn?iJhDWxVfOEk!+sH{Xo8o0P
zp^QMprKQJi*98(S1l^YsNVe=t2rZ~%D=Uz57J!hjN
z`61gKO`SXGxdYp3&((&T(3|uXN9ycr(X!mGe^bBWfOi$}t^&P9g?AO`9kcSL6y8LzE`tq_FLc?2ZP-Q-wx4BL
zo6QzSe=nfF>W4uGlzM``3B&QA!?^A8dm=+vnfW2s2{S)rhWuagF}`nkH@q)-KI9#D
z&v@$254(OummMMMMe;i7AYQb8$@Wp=gUk=)Y>&D@I>>RvvvXZxyCHUk-HO=kw(DZY
zaCk0T;?rfGEvGYUJZliJ({2|&t=HImq0F!HCALs3vxTj-wOQ8f!Z{rd5^j|8>*>-B
z_8NbKot871WmSs^HqRAHe15fnH57#iTjFzki7#aNOCf@Em)j<7J5qjO0bY3yEuo5$zgpQsG$h59#v<`$e%37kO9G*Q!OA%mN
z^5GyR)xCP7Hb`g!rsXrssz%2+Xgc?XP0PoKO@oY>oqauKN;_p{%1LnJj3YQRL)?&v
zDX2T*Q$4X+75h#DCUSkk>87wR&LYdmou^0WU?4y|_qf^%7=P!C$y%sXzLJ
zcr4r!8a)#+i@w6`=pG)3m7C(B+Heyub+B7bKnJZ(9fPqH8oj`38c~Zi@sa9W!6J5g
z64hz)*r-!0WHK|)K6jQ*Nz=&Plt2b2&Tsd1sDp=N=!KzzH`M(2PYkwKrxj(3*+cRKJuCRLr=%9Y+Pha*tIF#lHow^lLzhkzR*6Lv^498XcejC9WJeqSU>p#}xIUBQGI2$x&5u5`Dz$M|KkyJsmrVc4paJEVUGvIB0p`
znZf00e*l5C8%|00;nq|3d<&$q1oeq2rAPN03YylC-Ubp%9req#O1Ts54~L
zAd$BW$N)KKNL026>;W={e+S>UBY^M!qaXdl2M7QGAOHk_01yBIKmZ5;0U!VbfB+D9
zfC=FH|8V{P0CzAn4F~`MAOHk_01yBIKmZ5;0U!VbfB+%@*Z<%KKmZ5;0U!VbfB+Bx
z0zd!=00AHX1Ri_>`1k*#-ruw_-(Wt^e1dtNxyfuWv&@M9oBkL4zw6)kZ~9aIkng{I
z|Lps7-+O&GeXG7HAL0F~_fyCSK0p8n00AHX1b_e#00KY&2mpaMMt~i4gu~-bB3oR?
z?>pMew)XeYJ19D3{(8BxmM`!Z*pQ~NQQYK9NCQ0!UuK4CQuB1!l
z{APKzhh7-QrrX_mr>kk6pA0(2$C2j7R%R`~d6h41^tK!I&YmH+G4`xn-g51an);j-0tI^xn
zb2t|naa_C@b`tnCA(ax3-nq2NudJXqhp;6Py*{QR*3=h>1S6*ns#_at#q^4zx^>WK
zp{K@as%OuilFQsIZk5pc$XdO2RWCCzI_wArQH|Eqh1K-B(A*WY+MYSjD3*LF&u?yu
z_;qWVbY#>oTcSS7XA%B-Rg;}Q=W_%CC|C5>nroR>>!v0-HRE+eB5E5|;Bq5X>RQ$P
zoX0USp(>Zt*Z6`&qb%GMn<@%l#BD&aUR=rN@(PM|}nqgTxFn}t?aftD#=
zADS9+a2(E;-^`Zs8#sd%u~Ti=5Af@sq%4pcb(V6Bj39hlr8V@QmNR)CJ+mLXkL=8S
zZfqa3OHvnlRoK;HDcwWfrw@k^fqTp4Ea%H>e7W95Pb*Z%62AXG>i?w8|0?q@%zrTN
zWd4-d@PE?(m(0hR-)5d;s*IPp#av*%;Qu@S%ghz#1^-J7&&)I5XTHYV%lrS{*h~P{
zKmZ5;0U!VbfB+Bx0zd!=00AKIa1!v6VQp7vR5_70fI`FMlxeT$oR1vWw{(J7SKYih
zi$&T#O~6ftlpUEfr^t)u?UxZPMcrjNjn(DNlv6G;DDRvMONr7x34;@*O%cD73`kob
zKK!6RyoXTHc09a(azaj$_BA|OG3pM68@ETYQ8p@EI5(WI@HpAZT2iwAJ8XNY1#k
zFhr9hcng8Tb(6OYNa;}8AFw;gpi9~f!1w=2=8tU5x0#pF7kq#K5C8%|00;m9AOHk_
z01yBIKmZ5;fj61JC6bK19o@L+3!CV-1L&tPo8_OIy!;EDRQeoxFT||3LZ{1ab1o
zvvzVOkl(EE*RyL|E4-xKZTCb!fwvwql_&TT&SVeY|0kI*+L-S!Uu3@iCN~g@2LeC<
z2mk>f00e*l5C8%|00;m9AOHkjj{yFifq8d;93f|jzD)qTkDPGn8vywJznyv6#=MID
zfe#P>0zd!=00AHX1b_e#00KY&2mk>fa32%!*vT;>yHzTo=V|PD2y~J!A2$%H4g`Pz5C8%|00;m9AOHk_01yBIKmZ6}0v>$_!0sl;T*?LjzW@Iy
z`QJA5fB%1k=`vTDQUAC7pG8mpKk(=L=X~Gvea812KGC=2{jvA+-uHRm>E%4HdA{Iz
zzo+V1@{sN?yZ^xbOYUoK&h?7x1=sVgCtY#p51s$${4M8>bB2D6{v!Q;`bm1)@eRi(
z98WnGhkiWtxuN$BT_2jDUZ(z#>QX6GG<<*n5O`Avq#mUwA`@d{uBO;(MxWlRcg3!-
z+v)ad^;Q(870_Sy9XOqfCD5NFmy73Oxuq*_qo*QMs^Pw{iyqJ`SeT+GFnf2NS4XfI2NCGa#
z&u8L`Tqd)S%grw@8%^uYT79==QD$BeaIqvZUEr2d$TXQ;GFEoG(X%QugOa)V<=A{I
z%Ox^PsoZ=rWi+cE3W&lnuvwJM&1YiE*lcAbvyw?J8qK<*(6BHoqhws#a%=^GT}du2
zr;^5g+--{(`my3xaXL0!jLmZk$(6;0<>Z2~yheTBsYnTusmA7;7bIFHPh6Gong
zVx!S|+M>W4l7Ne6V%hl&>cy4C#7Zh|tg5iTEw(Kz(>R@rCs9|VxLkTU9nUVujAgdk
z=yAnXl`TpFE|!~LnNM>3%Hn)Ho}Q;?A~UMzw8dI*en3$u(
zk+2~Pi%KI~F19qkh^oxT_WoeAcOa>3wir()lk?G8I+zNg(hfwSx@TF^29|KSSaxwaJ2yjz
zQX!D=dz2a*DujribDpC`tDvgy4`L)ZSXRK)f$$w87${Ad@>VX
ziAI#F4K|Y1m*RIIVq7@%s#@>fhcZpQ@C}khyqIEV)F}`
z<)!&?dOWJN);xC^a+hYWST4ygq^2(@wQn`*w+tRAYZq}Mw~$>-#`#NOIuymuD)hE{
zZG+p%s%a^eLrV-Aw3pA*;b<5$>9u!z&Dya#XOwhqk&k8L3yCp$Vovu1^Llh3bh=`r
zBYT0gCgpNVsZ2UDN(bjK_(mOdhd$UeeU!?r=lx0xL9SB%Z?Q7sH%iyW^!LeL|z5!p%~+y-?eM8W_6|D(>2+nD!ypZD54|LXpV
z|LdNx_mAC8|26+>o<}{O@m}|id47+H`#&E@TDHg{jsTqZpRWyoK=Etb8uA+yYST@H|<0<0g
z&9KXo&dEDp(Qzu6A_i}B&9YP|MVzp&H8VwVDMH@&8N4NRNh)TrJn|N5B1K4hJA;*`
z!&ERq4BC&nI7v+;l!^@4AA00G6-W>Rgi{mLctWni0FAduASix$;X7mCTMzUkuVv1?Vkhgi9D+=c%`+<8e!BHwOM_BH(oSmRJ
zwc34SUCBFODBL{W6@@dC)RdY<|Cm=)MhvC&jeAAov>}uJv9G9{I!}e>hygnu!xCNV
z9S-IKH5Ma|&CTlNsDF$K#0YbrsWM-f3L#nF%&bbilC*zfR;3;(Uvqz{GPfi%Ps^&z
zCCU06SJ`)Po~ME_G#pL0SX4t=GBo#*DsxCx?e88{IwWP=*E6b+l1%$MMU|2h)I^N1
znvYctdx#o|xsZlF9jhXI|KB!z9|QnUMIZnKfB+Bx0zd!=00AHX1b_e#00KbZrzU{!
z|HJkFPmK=@fdCKy0zd!=00AHX1b_e#00KY&2;3J0;Qs%8(M?cEAOHk_01yBIKmZ5;
z0U!VbfB+Bx0&j=_T>rlzd|(U&fB+Bx0zd!=00AHX1b_e#00KbZz90bC|Mx{VK_!6z
z5C8%|00;m9AOHk_01yBIKmZ85Ap-dNpJYB@W4_9Kfcfh;loc2Q0U!VbfB+Bx0zd!=
z00AHX1b_e#00KV|flGwj7PiGgPN&V56K=KIrWbRo&+eDsB`{)l+s0AuE~LATUZTpD
z(VL$y+N2CdoGzO!-LCGTH>p}Y{2#p$0AK$T%=c{QKllIvAOHk_01yBIKmZ5;0U!Vb
zfB+Bx0uK@a7eNN>_+5bHzvJtFJM&!|^BVdGK0p8n00AHX1b_e#00KY&2mk>f00e-*
z14DqalZ4Yw5hUS4za7BW|F7DZSDDuymafB+Bx0zd!=00AHX1b_e#00KY&2mk>G
zMK}q&eTX7lE;~H`4;%sr00AHX1b_e#00KY&2mk>f00e-*gHHh8|F;c4`0a!i0s$ZZ
z1b_e#00KY&2mk>f00e*l5O|mg!2SP+x%Z*%KmZ5;0U!VbfB+Bx0zd!=00AKIun>Uz
z{|`$~Lpy;05C8%|00;m9AOHk_01yBIK;U5};A4JZ8>Y)PW`X$|KgSIDzv5$j-|}vF
zU-Ep&JMNzG)SVx8{fI6*Lez`ob<#n+X#bM!qr?YoPoh8QbJPveL5?Gyo$CtQ4fOiK
z-HO=kw(BB#N#QO(>ImLQ5Vm@=Cf@Em)j)4CtO&hsOFFMqg|4{UY9Cg_+to&|Q*Sjp
zm6%21*okGR#huHR_;i_P%jwJ-&l>8%PP<+Bv|eNLg)+a&m)JtF%oeuR)@E6^3)l8=
zkZ_}vUr(2Au-Et-?6jQ8EUQ{Xuz9Xn;`6Hotf44G*b<-POMD^AZ{m7%*y(!hn68$I
zQClG=8Mq_N4~G6C&kdqyXkjI0I0+p+6Oeo4iq<0sLK|g`d!(Wqo;^lO5nx*K;UFgH
zWs;5BAfXAEmd`A!8Xe=H>D(JOEgvH`4Kij%J$EuQrJXV}FeZ`XHwLKls3gXi>*pT2q`uaPQ_YuE1`
z6-k7Kme#6SR?>4v?yQ`qacACryF|W*W|?k%U##rZ#YU~ucRFI~k3JzD3%7(u&qU0k
zuW&oMhX-Qirg*3}+{8;A?3NSIL90{8U@V13FR+?M)M8D1q&ioyh@GB9b(%aj>eLFE
z%*?aTouyOKG;%j3JFs}Wt03&^ho?>W41$Ln-DAUaFcKl|7A17styV)6nhHJh*(no1
zvrK1bIR>qlGu0s;96!A7r-Nf-#B;O+Q*lz|i!tHT9N2P3#iMkm)u@SW!xUs1rUycI
z&yw3ty&(>sa(g>F_1l(qcs{9A+amg^u`4J#Dd*RDTn|aG->TKoq+}rx(A;WHe!Y1>
zYnW+7%c*(*vQ@}GzQwcCxS9IiphC;IvQx1mjT=CMui3#FvTAmS5G$GHz
zIIfV7e5U@;)3GZX7a0?FZ3?$!IFS3~LA$;$v=1jO%{n4lg=$EwN-=hMJ=t!xkrP-h
zT_()9!n3SayJM56E-?lpTB>nzLa-A)M{spw5NsWl9GJ*B{lDy{gW)i7BnJ_<#I_VA
ztKS|IZ8a6MmS%mm}o7yff8}7)Q#}Uki2i00FvJz8|yhlS$Y1vS!e9#tm
zL==3YFv@V~GF99lxR<&=wplbsBbF6Ux*Wm5fuNztO3ZNLK6;l!^6_U$$;b7TR6n9k
zmDkfxuKPeYmX>KHl$&0Tm^ur^Sv7G-=ry{Q-g@tmgW8=5`XndR+k!e2$30RE;XIlr
zr5X5+90@3sit
zD+i7!bua2MMSbYVOGr*~RF#}WAMyH;-GoI?$4;W1SvD6-Ed?eHS{`_2v7gkbnkKrq
z){jYa>F&hW@A&us9y((4|E=%cUdr=l?oYYj?fO;MhVu&jU6c$TAOHk_01yBI4>y6M
zt7B4Nd*>b6)sN7q3ms{ryP_OkFfIEys8;EC^Y`cwK0p8n00AHX1b_e#00KY&2mk>f00e-*{~rRr
zA<8!H$_ckxZS=$Xj*l{))?6fI3lQt*7x{Ldow9|cL$)mJ+C2ni8*`@H=(p6j#Qy>K
C0yi@N
literal 0
HcmV?d00001
diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py
index 2a1c7c960..709c71390 100644
--- a/tests/test_db_migrations.py
+++ b/tests/test_db_migrations.py
@@ -27,6 +27,10 @@
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")),
+ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_101")),
+ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_102")),
+ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_103")),
+ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_104")),
],
)
def test_library_migrations(path: str):
@@ -51,4 +55,4 @@ def test_library_migrations(path: str):
except Exception as e:
library.close()
shutil.rmtree(temp_path)
- raise (e)
+ raise e
From f2f062639a9329f0b6dc6f25a4a89c01d4107d66 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Tue, 31 Mar 2026 00:12:52 +0200
Subject: [PATCH 03/12] add translation keys.
---
src/tagstudio/qt/mixed/build_tag.py | 4 ++--
src/tagstudio/resources/translations/en.json | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index 92dc8b391..c01cb5a05 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -180,7 +180,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
self.category_layout.setContentsMargins(0, 0, 0, 0)
self.category_layout.setSpacing(0)
self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
- self.category_layout.addWidget(QLabel("Categories"))
+ self.category_layout.addWidget(QLabel(Translations["tag.categories"]))
self.category_button_group = QButtonGroup(self)
self.category_button_group.setExclusive(False)
@@ -501,7 +501,7 @@ def __build_category_row_widget(
include_button = QRadioButton()
include_button.setObjectName(f"categoryExclusionButton.{category.id}")
include_button.setFixedSize(22, 22)
- include_button.setToolTip("Show in category")
+ include_button.setToolTip(Translations["tag.categories.tooltip"])
include_button.setStyleSheet(
f"""
QRadioButton{{
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index cdd46dc11..a6974cdfd 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -319,6 +319,8 @@
"tag.add": "Add Tag",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
+ "tag.categories": "Categories",
+ "tag.categories.tooltip": "Show tag in this category",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
From e1184838cfb7cb1e4e3c1c3e6ac038d9cfb75526 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Wed, 1 Apr 2026 00:06:47 +0200
Subject: [PATCH 04/12] make include_button a checkbox, cleanup.
---
src/tagstudio/qt/mixed/build_tag.py | 215 ++++++++++------------------
1 file changed, 74 insertions(+), 141 deletions(-)
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index c01cb5a05..bd709ca3c 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -1,8 +1,6 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
-
-
import sys
from typing import cast, override
@@ -182,9 +180,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.category_layout.addWidget(QLabel(Translations["tag.categories"]))
- self.category_button_group = QButtonGroup(self)
- self.category_button_group.setExclusive(False)
-
self.category_scroll_contents = QWidget()
self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents)
@@ -246,31 +241,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
text_color: QColor = get_text_color(primary_color, highlight_color)
self.cat_checkbox.setStyleSheet(
- f"""
- QCheckBox{{
- background: rgba{primary_color.toTuple()};
- color: rgba{text_color.toTuple()};
- border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
- border-style: solid;
- border-width: 2px;
- }}
- QCheckBox::indicator{{
- width: 10px;
- height: 10px;
- border-radius: 2px;
- margin: 4px;
- }}
- QCheckBox::indicator:checked{{
- background: rgba{text_color.toTuple()};
- }}
- QCheckBox::hover{{
- border-color: rgba{highlight_color.toTuple()};
- }}
- QCheckBox::focus{{
- border-color: rgba{highlight_color.toTuple()};
- outline: none;
- }}"""
+ self.__checkbox_stylesheet(primary_color, border_color, highlight_color, text_color)
)
self.cat_layout.addWidget(self.cat_checkbox)
self.cat_layout.addWidget(self.cat_title)
@@ -287,31 +258,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
self.hidden_checkbox.setFixedSize(22, 22)
self.hidden_checkbox.setStyleSheet(
- f"""
- QCheckBox{{
- background: rgba{primary_color.toTuple()};
- color: rgba{text_color.toTuple()};
- border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
- border-style: solid;
- border-width: 2px;
- }}
- QCheckBox::indicator{{
- width: 10px;
- height: 10px;
- border-radius: 2px;
- margin: 4px;
- }}
- QCheckBox::indicator:checked{{
- background: rgba{text_color.toTuple()};
- }}
- QCheckBox::hover{{
- border-color: rgba{highlight_color.toTuple()};
- }}
- QCheckBox::focus{{
- border-color: rgba{highlight_color.toTuple()};
- outline: none;
- }}"""
+ self.__checkbox_stylesheet(primary_color, border_color, highlight_color, text_color)
)
self.hidden_layout.addWidget(self.hidden_checkbox)
self.hidden_layout.addWidget(self.hidden_title)
@@ -338,6 +285,60 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
self.set_tag(tag or Tag(name=Translations["tag.new"]))
+ @staticmethod
+ def __checkbox_stylesheet(
+ primary_color: QColor, border_color: QColor, highlight_color: QColor, text_color: QColor
+ ) -> str:
+ return f"""
+ QCheckBox{{
+ background: rgba{primary_color.toTuple()};
+ color: rgba{text_color.toTuple()};
+ border-color: rgba{border_color.toTuple()};
+ border-radius: 6px;
+ border-style: solid;
+ border-width: 2px;
+ }}
+ QCheckBox::indicator{{
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ margin: 4px;
+ }}
+ QCheckBox::indicator:checked{{
+ background: rgba{text_color.toTuple()};
+ }}
+ QCheckBox::hover{{
+ border-color: rgba{highlight_color.toTuple()};
+ }}
+ QCheckBox::focus{{
+ border-color: rgba{highlight_color.toTuple()};
+ outline: none;
+ }}"""
+
+ @staticmethod
+ def __tag_colors(tag: Tag) -> tuple[QColor, QColor, QColor, QColor]:
+ primary_color = get_primary_color(tag)
+
+ border_color = (
+ get_border_color(primary_color)
+ if not (tag.color and tag.color.secondary and tag.color.color_border)
+ else (QColor(tag.color.secondary))
+ )
+
+ highlight_color = get_highlight_color(
+ primary_color
+ if not (tag.color and tag.color.secondary)
+ else QColor(tag.color.secondary)
+ )
+
+ text_color: QColor
+ if tag.color and tag.color.secondary:
+ text_color = QColor(tag.color.secondary)
+ else:
+ text_color = get_text_color(primary_color, highlight_color)
+
+ return primary_color, border_color, highlight_color, text_color
+
def backspace(self):
focused_widget = QApplication.focusWidget()
row = self.aliases_table.rowCount()
@@ -461,32 +462,12 @@ def set_categories(
def __is_removed_parent(self, tag: Tag) -> bool:
return tag in self.tag.parent_tags and tag.id not in self.parent_ids
- def __build_category_row_widget(
- self, category: Tag
- ) -> tuple[QPushButton, QRadioButton, QWidget]:
+ def __build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]:
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
- # Init Colors
- primary_color = get_primary_color(category)
- border_color = (
- get_border_color(primary_color)
- if not (category.color and category.color.secondary and category.color.color_border)
- else (QColor(category.color.secondary))
- )
- highlight_color = get_highlight_color(
- primary_color
- if not (category.color and category.color.secondary)
- else QColor(category.color.secondary)
- )
- text_color: QColor
- if category.color and category.color.secondary:
- text_color = QColor(category.color.secondary)
- else:
- text_color = get_text_color(primary_color, highlight_color)
-
# Add Tag Widget
tag_widget = TagWidget(
category,
@@ -498,55 +479,22 @@ def __build_category_row_widget(
row.addWidget(tag_widget)
# Add Category Exclusion Tag Button
- include_button = QRadioButton()
- include_button.setObjectName(f"categoryExclusionButton.{category.id}")
- include_button.setFixedSize(22, 22)
- include_button.setToolTip(Translations["tag.categories.tooltip"])
- include_button.setStyleSheet(
- f"""
- QRadioButton{{
- background: rgba{primary_color.toTuple()};
- color: rgba{text_color.toTuple()};
- border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
- border-style: solid;
- border-width: 2px;
- }}
- QRadioButton::indicator{{
- width: 10px;
- height: 10px;
- border-radius: 2px;
- margin: 4px;
- }}
- QRadioButton::indicator:checked{{
- background: rgba{text_color.toTuple()};
- }}
- QRadioButton::hover{{
- border-color: rgba{highlight_color.toTuple()};
- }}
- QRadioButton::pressed{{
- background: rgba{border_color.toTuple()};
- color: rgba{primary_color.toTuple()};
- border-color: rgba{primary_color.toTuple()};
- }}
- QRadioButton::focus{{
- border-color: rgba{highlight_color.toTuple()};
- outline: none;
- }}"""
- )
+ include_checkbox = QCheckBox()
+ include_checkbox.setFixedSize(22, 22)
+ include_checkbox.setToolTip(Translations["tag.categories.tooltip"])
+ include_checkbox.setStyleSheet(self.__checkbox_stylesheet(*self.__tag_colors(category)))
- include_button.clicked.connect(
- lambda: self.update_category_exclusion(category, include_button.isChecked())
- )
- self.category_button_group.addButton(include_button)
if category.id not in self.exclusion_ids:
- include_button.setChecked(True)
+ include_checkbox.setChecked(True)
+ include_checkbox.toggled.connect(
+ lambda checked: self.__update_category_exclusion(category, checked)
+ )
- row.addWidget(include_button)
+ row.addWidget(include_checkbox)
- return tag_widget.bg_button, include_button, container
+ return tag_widget.bg_button, include_checkbox, container
- def update_category_exclusion(self, category: Tag, checked: bool) -> None:
+ def __update_category_exclusion(self, category: Tag, checked: bool) -> None:
if checked:
self.exclusion_ids.remove(category.id)
else:
@@ -568,7 +516,7 @@ def set_parent_tags(self):
if not tag:
continue
is_disam = parent_id == self.disambiguation_id
- last_tab, next_tab, container = self.__build_row_item_widget(tag, parent_id, is_disam)
+ last_tab, next_tab, container = self.__build_parent_row_widget(tag, parent_id, is_disam)
layout.addWidget(container)
# TODO: Disam buttons after the first currently can't be added due to this error:
# QWidget::setTabOrder: 'first' and 'second' must be in the same window
@@ -577,30 +525,12 @@ def set_parent_tags(self):
self.setTabOrder(next_tab, self.name_field)
self.parent_tags_scroll_layout.addWidget(c)
- def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: bool):
+ def __build_parent_row_widget(self, tag: Tag, parent_id: int, is_disambiguation: bool):
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
- # Init Colors
- primary_color = get_primary_color(tag)
- border_color = (
- get_border_color(primary_color)
- if not (tag.color and tag.color.secondary and tag.color.color_border)
- else (QColor(tag.color.secondary))
- )
- highlight_color = get_highlight_color(
- primary_color
- if not (tag.color and tag.color.secondary)
- else QColor(tag.color.secondary)
- )
- text_color: QColor
- if tag.color and tag.color.secondary:
- text_color = QColor(tag.color.secondary)
- else:
- text_color = get_text_color(primary_color, highlight_color)
-
# Add Tag Widget
tag_widget = TagWidget(
tag,
@@ -612,6 +542,9 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b
tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).edit_tag(t))
row.addWidget(tag_widget)
+ # Init Colors
+ primary_color, border_color, highlight_color, text_color = self.__tag_colors(tag)
+
# Add Disambiguation Tag Button
disam_button = QRadioButton()
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
@@ -706,7 +639,7 @@ def _set_aliases(self):
alias_name = alias.name if alias else self.new_alias_names[alias_id]
- # handel when an alias name changes
+ # handle when an alias name changes
if alias_id in self.new_alias_names:
alias_name = self.new_alias_names[alias_id]
From 66742d9c528307ac39ae6512c761d91be1399c45 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Wed, 1 Apr 2026 00:13:40 +0200
Subject: [PATCH 05/12] add missing translation for the properties label.
---
src/tagstudio/qt/mixed/build_tag.py | 2 +-
src/tagstudio/resources/translations/en.json | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index bd709ca3c..4cd79231b 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -272,7 +272,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
self.root_layout.addWidget(self.parent_tags_widget)
self.root_layout.addWidget(self.category_widget)
self.root_layout.addWidget(self.color_widget)
- self.root_layout.addWidget(QLabel("Properties
"))
+ self.root_layout.addWidget(QLabel(f"{Translations['tag.properties']}
"))
self.root_layout.addWidget(self.cat_widget)
self.root_layout.addWidget(self.hidden_widget)
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index a6974cdfd..54444666d 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -335,6 +335,7 @@
"tag.parent_tags.add": "Add Parent Tag(s)",
"tag.parent_tags.description": "This tag can be treated as a substitute for any of these Parent Tags in searches.",
"tag.parent_tags": "Parent Tags",
+ "tag.properties": "Properties",
"tag.remove": "Remove Tag",
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",
From fa8b2aaa03e04d6d0d7530fdf130d73971641acc Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Thu, 2 Apr 2026 17:06:15 +0200
Subject: [PATCH 06/12] add tests.
---
src/tagstudio/qt/mixed/field_containers.py | 1 -
tests/qt/test_build_tag_panel.py | 203 +++++++++++++++++++++
tests/qt/test_field_containers.py | 26 ++-
3 files changed, 228 insertions(+), 2 deletions(-)
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index e804c6c2b..675195d46 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -124,7 +124,6 @@ def update_granular(
self.write_tag_container(
container_index, tags=tags, category_tag=cat, is_mixed=False
)
-
container_index += 1
container_len += 1
if update_badges:
diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py
index 9ae15726b..8fd6c0d88 100644
--- a/tests/qt/test_build_tag_panel.py
+++ b/tests/qt/test_build_tag_panel.py
@@ -5,12 +5,14 @@
from collections.abc import Callable
+from PySide6.QtWidgets import QCheckBox
from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagAlias
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.build_tag import BuildTagPanel, CustomTableItem
+from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
@@ -178,3 +180,204 @@ def test_build_tag_panel_build_tag(qtbot: QtBot, library: Library):
tag: Tag = panel.build_tag()
assert tag.name == Translations["tag.new"]
+
+
+def test_build_tag_panel_show_category_from_parent(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
+ child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == parent
+
+
+def test_build_tag_panel_show_category_from_grandparent(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
+ child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == grandparent
+
+
+def test_build_tag_panel_add_category_through_parent(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
+ child = unwrap(library.add_tag(generate_tag("child", id=124)))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ assert __find_category_tag_widget(panel) is None
+
+ child.parent_tags.add(parent)
+
+ panel.add_parent_tag_callback(parent.id)
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == parent
+
+
+def test_build_tag_panel_add_category_through_grandparent(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
+ child = unwrap(library.add_tag(generate_tag("child", id=124)))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ assert __find_category_tag_widget(panel) is None
+
+ child.parent_tags.add(parent)
+
+ panel.add_parent_tag_callback(parent.id)
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == grandparent
+
+
+def test_build_tag_panel_remove_category_through_parent(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
+ child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == parent
+
+ panel.remove_parent_tag_callback(parent.id)
+
+ assert __find_category_tag_widget(panel) is None
+
+
+def test_build_tag_panel_remove_category_through_grandparent(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
+ child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == grandparent
+
+ panel.remove_parent_tag_callback(parent.id)
+
+ assert __find_category_tag_widget(panel) is None
+
+
+def test_build_tag_panel_exclude_from_category(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
+ child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ assert len(panel.exclusion_ids) == 0
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+
+ checkbox = __find_include_checkbox(tag_widget)
+ assert checkbox.isChecked()
+
+ checkbox.click()
+
+ assert parent.id in panel.exclusion_ids
+
+
+def test_build_tag_panel_include_in_category(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
+ child = unwrap(
+ library.add_tag(
+ generate_tag("child", id=124, parent_tags={parent}, category_exclusions={parent})
+ )
+ )
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ assert parent.id in panel.exclusion_ids
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+
+ checkbox = __find_include_checkbox(tag_widget)
+ assert not checkbox.isChecked()
+
+ checkbox.click()
+
+ assert len(panel.exclusion_ids) == 0
+
+
+def test_build_tag_panel_remove_duplicate_category_retained(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
+ other_parent = unwrap(
+ library.add_tag(generate_tag("other_parent", id=124, parent_tags={grandparent}))
+ )
+ child = unwrap(
+ library.add_tag(generate_tag("child", id=125, parent_tags={parent, other_parent}))
+ )
+
+ panel: BuildTagPanel = BuildTagPanel(library, child)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == grandparent
+
+ panel.remove_parent_tag_callback(parent.id)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == grandparent
+
+
+def __find_category_tag_widget(panel: BuildTagPanel) -> TagWidget | None:
+ item = panel.category_scroll_layout.itemAt(0)
+ while item is not None:
+ if isinstance(item.widget(), TagWidget):
+ break
+ item = item.widget().layout().itemAt(0)
+
+ if item is not None:
+ return item.widget()
+ return None
+
+
+def __find_include_checkbox(tag_widget: TagWidget) -> QCheckBox:
+ layout_item = tag_widget.parentWidget().layout().itemAt(1)
+ assert layout_item is not None
+
+ widget = layout_item.widget()
+ assert isinstance(widget, QCheckBox)
+
+ return widget
diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py
index 2b9921146..d762284a9 100644
--- a/tests/qt/test_field_containers.py
+++ b/tests/qt/test_field_containers.py
@@ -1,7 +1,8 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
-
+from collections.abc import Callable
+from pathlib import Path
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
@@ -185,3 +186,26 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full:
assert container.title != "Tags
"
case _:
pass
+
+
+def test_exclude_tag_category(
+ qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag]
+):
+ panel = PreviewPanel(library, qt_driver)
+
+ category_parent = unwrap(generate_tag("category_parent", id=123, is_category=True))
+ library.add_tag(category_parent)
+
+ tag = unwrap(generate_tag("tag", id=124))
+ library.add_tag(tag, parent_ids={category_parent.id}, exclusion_ids={category_parent.id})
+
+ entry = Entry(id=777, folder=unwrap(library.folder), path=Path("test.txt"), fields=[])
+
+ library.add_entries([entry])
+ library.add_tags_to_entries(entry.id, tag.id)
+
+ qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
+ panel.set_selection(qt_driver.selected)
+
+ assert len(panel.field_containers_widget.containers) == 1
+ assert panel.field_containers_widget.containers[0].title == "Tags
"
From 8c68cb4f66a0c381493c2b0b024041b59a801f21 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Thu, 2 Apr 2026 17:36:19 +0200
Subject: [PATCH 07/12] update docs.
---
docs/library-changes.md | 68 +++++++++++++++++++++++------------------
docs/tags.md | 22 +++++++------
2 files changed, 50 insertions(+), 40 deletions(-)
diff --git a/docs/library-changes.md b/docs/library-changes.md
index 6de13d274..29b9d727c 100644
--- a/docs/library-changes.md
+++ b/docs/library-changes.md
@@ -15,7 +15,7 @@ Legacy (JSON) library save format versions were tied to the release version of t
### Versions 1.0.0 - 9.4.2
| Used From | Format | Location |
-| --------- | ------ | --------------------------------------------- |
+|-----------|--------|-----------------------------------------------|
| v1.0.0 | JSON | ``/.TagStudio/ts_library.json |
The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0.
@@ -49,7 +49,7 @@ These versions were used while developing the new SQLite file format, outside an
### Version 6
| Used From | Format | Location |
-| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|---------------------------------------------------------------------------------|--------|-------------------------------------------------|
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | ``/.TagStudio/ts_library.sqlite |
The first public version of the SQLite save file format.
@@ -61,74 +61,82 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 7
| Used From | Format | Location |
-| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|---------------------------------------------------------------------------------|--------|-------------------------------------------------|
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.
-- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
+- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.
+- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
---
### Version 8
| Used From | Format | Location |
-| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|---------------------------------------------------------------------------------|--------|-------------------------------------------------|
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
-- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
-- Updates Neon colors to use the new `color_border` property.
+- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
+- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
+- Updates Neon colors to use the new `color_border` property.
---
### Version 9
| Used From | Format | Location |
-| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|-------------------------------------------------------------------------|--------|-------------------------------------------------|
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
+- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
---
### Version 100
| Used From | Format | Location |
-| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|------------------------------------------------------------------------------------------------------|--------|-------------------------------------------------|
| [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Introduces built-in minor versioning
- - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
- - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased.
-- Swaps `parent_id` and `child_id` values in the `tag_parents` table
+- Introduces built-in minor versioning
+ - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
+ - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased.
+- Swaps `parent_id` and `child_id` values in the `tag_parents` table
#### Version 101
| Used From | Format | Location |
-| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|-------------------------------------------------------------------------|--------|-------------------------------------------------|
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Deprecates the `preferences` table, set to be removed in a future TagStudio version.
-- Introduces the `versions` table
- - Has a string `key` column and an int `value` column
- - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'`
- - `'INITIAL'` stores the database version number in which in was created
- - Pre-existing databases set this number to `100`
- - `'CURRENT'` stores the current database version number
+- Deprecates the `preferences` table, set to be removed in a future TagStudio version.
+- Introduces the `versions` table
+ - Has a string `key` column and an int `value` column
+ - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'`
+ - `'INITIAL'` stores the database version number in which in was created
+ - Pre-existing databases set this number to `100`
+ - `'CURRENT'` stores the current database version number
#### Version 102
| Used From | Format | Location |
-| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+|-------------------------------------------------------------------------|--------|-------------------------------------------------|
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
+- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
#### Version 103
-| Used From | Format | Location |
-| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
+| Used From | Format | Location |
+|--------------------------------------------------------------|--------|-------------------------------------------------|
| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | ``/.TagStudio/ts_library.sqlite |
-- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
-- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
\ No newline at end of file
+- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
+- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
+
+#### Version 104
+
+| Used From | Format | Location |
+|-----------|--------|-------------------------------------------------|
+| TBD | SQLite | ``/.TagStudio/ts_library.sqlite |
+
+- Introduces the `category_exclusions` table. Used for excluding a tag from being displayed in a specific category
\ No newline at end of file
diff --git a/docs/tags.md b/docs/tags.md
index b8cb65e1b..c4fb02716 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -10,9 +10,9 @@ Tags are discrete objects that represent some attribute. This could be a person,
TagStudio tags do not share the same naming limitations of many other tagging solutions. The key standouts of tag names in TagStudio are:
-- Tag names do **NOT** have to be unique
-- Tag names are **NOT** limited to specific characters
-- Tags can have **aliases**, a.k.a. alternate names to go by
+- Tag names do **NOT** have to be unique
+- Tag names are **NOT** limited to specific characters
+- Tags can have **aliases**, a.k.a. alternate names to go by
### Name
@@ -66,7 +66,7 @@ Lastly, when searching your files with broader categories such as `Character` or
!!! warning ""
- **_Coming in version 9.6.x_**
+**_Coming in version 9.6.x_**
Component tags will be built from a composition-based, or "HAS" type relationship between tags. This takes care of instances where an attribute may "have" another attribute, but doesn't inherit from it. Shrek may be an `Ogre`, he may be a `Character`, but he is NOT a `Leather Vest` - even if he's commonly seen _with_ it. Component tags, along with the upcoming "Tag Override" feature, are built to handle these cases in a way that still simplifies the tagging process without adding too much undue complexity for the user.
@@ -88,7 +88,7 @@ Custom palettes and colors can be created via the [Tag Color Manager](colors.md)
!!! warning ""
- **_Coming in version 9.6.x_**
+**_Coming in version 9.6.x_**
## Tag Properties
@@ -96,12 +96,14 @@ Properties are special attributes of tags that change their behavior in some way
#### Is Category
-The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. Tags inheriting from multiple "category tags" will still show up under any applicable category.
+The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. By default, tags inheriting from multiple "category tags" will still show up under any applicable category.
This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature of multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section.

+If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.
+
### Built-In Tags and Categories
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
@@ -114,7 +116,7 @@ Due to the nature of how tags and Tag Felids operated prior to v9.5, the organiz
!!! warning ""
- **_Coming in version 9.6.x_**
+**_Coming in version 9.6.x_**
When the "Is Hidden" property is checked, any file entries tagged with this tag will not show up in searches by default. This property comes by default with the built-in "Archived" tag.
@@ -123,7 +125,7 @@ When the "Is Hidden" property is checked, any file entries tagged with this tag
The following are examples of how a set of given tags will respond to various search queries.
| Tag | Name | Shorthand | Aliases | Parent Tags |
-| ------------------- | ------------------- | --------- | ---------------------- | -------------------------------------------- |
+|---------------------|---------------------|-----------|------------------------|----------------------------------------------|
| _League of Legends_ | "League of Legends" | "LoL" | ["League"] | ["Game", "Fantasy"] |
| _Arcane_ | "Arcane" | "" | [] | ["League of Legends", "Cartoon"] |
| _Jinx (LoL)_ | "Jinx Piltover" | "Jinx" | ["Jinxy", "Jinxy Poo"] | ["League of Legends", "Arcane", "Character"] |
@@ -133,7 +135,7 @@ The following are examples of how a set of given tags will respond to various se
**The query "Arcane" will display results tagged with:**
| Tag | Cause of Inclusion | Tag Tree Lineage |
-| --------------- | -------------------------------- | -------------------------- |
+|-----------------|----------------------------------|----------------------------|
| Arcane | Direct match of tag name | "Arcane" |
| Jinx (LoL) | Search term is set as parent tag | "Jinx (LoL) > Arcane" |
| Zander (Arcane) | Search term is set as parent tag | "Zander (Arcane) > Arcane" |
@@ -141,7 +143,7 @@ The following are examples of how a set of given tags will respond to various se
**The query "League of Legends" will display results tagged with:**
| Tag | Cause of Inclusion | Tag Tree Lineage |
-| ----------------- | ------------------------------------------------------ | ---------------------------------------------- |
+|-------------------|--------------------------------------------------------|------------------------------------------------|
| League of Legends | Direct match of tag name | "League of Legends" |
| Arcane | Search term is set as parent tag | "Arcane > League of Legends" |
| Jinx (LoL) | Search term is set as parent tag | "Jinx (LoL) > League of Legends" |
From 431b201f8ec1ef9ba0b3a484899cb8ac6f8d7f41 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Thu, 2 Apr 2026 20:18:12 +0200
Subject: [PATCH 08/12] fix categories when creating new tags.
---
src/tagstudio/qt/mixed/build_tag.py | 5 +++--
tests/qt/test_build_tag_panel.py | 29 +++++++++++++++++++++++++++--
2 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index 4cd79231b..fc69da62d 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -447,9 +447,10 @@ def set_categories(
layout.addWidget(container)
self.setTabOrder(last_tab, next_tab)
else:
- tag_ids = [self.tag.id]
+ tag_ids = {self.tag.id}
+ tag_ids.update(self.parent_ids)
if added_parent_id is not None:
- tag_ids.append(added_parent_id)
+ tag_ids.add(added_parent_id)
for tag in self.lib.get_tag_hierarchy(tag_ids).values():
if not tag.is_category:
diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py
index 8fd6c0d88..4fb5b27b6 100644
--- a/tests/qt/test_build_tag_panel.py
+++ b/tests/qt/test_build_tag_panel.py
@@ -361,8 +361,33 @@ def test_build_tag_panel_remove_duplicate_category_retained(
assert tag_widget.tag == grandparent
-def __find_category_tag_widget(panel: BuildTagPanel) -> TagWidget | None:
- item = panel.category_scroll_layout.itemAt(0)
+def test_build_tag_panel_new_tag_multiple_categories(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
+ other_parent = unwrap(library.add_tag(generate_tag("other_parent", id=124, is_category=True)))
+
+ panel: BuildTagPanel = BuildTagPanel(library)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is None
+
+ panel.add_parent_tag_callback(parent.id)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is not None
+ assert tag_widget.tag == parent
+
+ panel.add_parent_tag_callback(other_parent.id)
+
+ tag_widget = __find_category_tag_widget(panel, 1)
+ assert tag_widget is not None
+ assert tag_widget.tag == other_parent
+
+
+def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None:
+ item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index)
while item is not None:
if isinstance(item.widget(), TagWidget):
break
From c5aed0de50b9b590937d09ce6d88c569f90734e7 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Thu, 2 Apr 2026 21:41:06 +0200
Subject: [PATCH 09/12] link PR in library-changes.md.
---
docs/library-changes.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/library-changes.md b/docs/library-changes.md
index 29b9d727c..aaefdbd9d 100644
--- a/docs/library-changes.md
+++ b/docs/library-changes.md
@@ -135,8 +135,8 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 104
-| Used From | Format | Location |
-|-----------|--------|-------------------------------------------------|
-| TBD | SQLite | ``/.TagStudio/ts_library.sqlite |
+| Used From | Format | Location |
+|--------------------------------------------------------------|--------|-------------------------------------------------|
+| [#1336](https://github.com/TagStudioDev/TagStudio/pull/1336) | SQLite | ``/.TagStudio/ts_library.sqlite |
- Introduces the `category_exclusions` table. Used for excluding a tag from being displayed in a specific category
\ No newline at end of file
From 26710a1953b7d89f8c570924da9b30d34f38aa53 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Sat, 4 Apr 2026 11:02:17 +0200
Subject: [PATCH 10/12] fix: don't show categories for tags that define the
category.
---
src/tagstudio/qt/mixed/build_tag.py | 2 +-
tests/qt/test_build_tag_panel.py | 11 +++++++++++
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index fc69da62d..fc3300b52 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -453,7 +453,7 @@ def set_categories(
tag_ids.add(added_parent_id)
for tag in self.lib.get_tag_hierarchy(tag_ids).values():
- if not tag.is_category:
+ if not tag.is_category or tag == self.tag:
continue
last_tab, next_tab, container = self.__build_category_row_widget(tag)
layout.addWidget(container)
diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py
index 4fb5b27b6..6e4d54426 100644
--- a/tests/qt/test_build_tag_panel.py
+++ b/tests/qt/test_build_tag_panel.py
@@ -386,6 +386,17 @@ def test_build_tag_panel_new_tag_multiple_categories(
assert tag_widget.tag == other_parent
+def test_build_tag_panel_category_not_shown_for_self(
+ qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
+):
+ library.add_tag(generate_tag("category", id=123, is_category=True))
+
+ panel: BuildTagPanel = BuildTagPanel(library)
+ qtbot.addWidget(panel)
+
+ tag_widget = __find_category_tag_widget(panel)
+ assert tag_widget is None
+
def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None:
item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index)
while item is not None:
From f9aa0f5dd394c5d52c564815de27c10dd73222e2 Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Sat, 4 Apr 2026 11:37:11 +0200
Subject: [PATCH 11/12] fix: persist exclusion_ids on all relevant add_tag
calls.
---
src/tagstudio/qt/mixed/tag_database.py | 1 +
src/tagstudio/qt/mixed/tag_search.py | 1 +
src/tagstudio/qt/ts_qt.py | 1 +
3 files changed, 3 insertions(+)
diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py
index 180cee9c7..db510f7e5 100644
--- a/src/tagstudio/qt/mixed/tag_database.py
+++ b/src/tagstudio/qt/mixed/tag_database.py
@@ -48,6 +48,7 @@ def build_tag(self, name: str):
parent_ids=panel.parent_ids,
alias_names=panel.alias_names,
alias_ids=panel.alias_ids,
+ exclusion_ids=panel.exclusion_ids
),
self.modal.hide(),
self.update_tags(self.search_field.text()),
diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py
index 12d2a74b2..6a5847e3f 100644
--- a/src/tagstudio/qt/mixed/tag_search.py
+++ b/src/tagstudio/qt/mixed/tag_search.py
@@ -189,6 +189,7 @@ def on_tag_modal_saved():
set(self.build_tag_modal.parent_ids),
set(self.build_tag_modal.alias_names),
set(self.build_tag_modal.alias_ids),
+ set(self.build_tag_modal.exclusion_ids)
)
self.add_tag_modal.hide()
diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py
index ab81b4e27..ed163e9b1 100644
--- a/src/tagstudio/qt/ts_qt.py
+++ b/src/tagstudio/qt/ts_qt.py
@@ -863,6 +863,7 @@ def add_tag_action_callback(self):
set(panel.parent_ids),
set(panel.alias_names),
set(panel.alias_ids),
+ set(panel.exclusion_ids)
),
self.modal.hide(),
)
From d6a65d69e4d2fe2a301bbf34b562d731980aaa8d Mon Sep 17 00:00:00 2001
From: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
Date: Sat, 4 Apr 2026 11:40:10 +0200
Subject: [PATCH 12/12] fix formatting.
---
src/tagstudio/qt/mixed/tag_database.py | 2 +-
src/tagstudio/qt/mixed/tag_search.py | 2 +-
src/tagstudio/qt/ts_qt.py | 2 +-
tests/qt/test_build_tag_panel.py | 1 +
4 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py
index db510f7e5..40fbf276c 100644
--- a/src/tagstudio/qt/mixed/tag_database.py
+++ b/src/tagstudio/qt/mixed/tag_database.py
@@ -48,7 +48,7 @@ def build_tag(self, name: str):
parent_ids=panel.parent_ids,
alias_names=panel.alias_names,
alias_ids=panel.alias_ids,
- exclusion_ids=panel.exclusion_ids
+ exclusion_ids=panel.exclusion_ids,
),
self.modal.hide(),
self.update_tags(self.search_field.text()),
diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py
index 6a5847e3f..583676c52 100644
--- a/src/tagstudio/qt/mixed/tag_search.py
+++ b/src/tagstudio/qt/mixed/tag_search.py
@@ -189,7 +189,7 @@ def on_tag_modal_saved():
set(self.build_tag_modal.parent_ids),
set(self.build_tag_modal.alias_names),
set(self.build_tag_modal.alias_ids),
- set(self.build_tag_modal.exclusion_ids)
+ set(self.build_tag_modal.exclusion_ids),
)
self.add_tag_modal.hide()
diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py
index ed163e9b1..66c56a1c6 100644
--- a/src/tagstudio/qt/ts_qt.py
+++ b/src/tagstudio/qt/ts_qt.py
@@ -863,7 +863,7 @@ def add_tag_action_callback(self):
set(panel.parent_ids),
set(panel.alias_names),
set(panel.alias_ids),
- set(panel.exclusion_ids)
+ set(panel.exclusion_ids),
),
self.modal.hide(),
)
diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py
index 6e4d54426..231cb7cf4 100644
--- a/tests/qt/test_build_tag_panel.py
+++ b/tests/qt/test_build_tag_panel.py
@@ -397,6 +397,7 @@ def test_build_tag_panel_category_not_shown_for_self(
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
+
def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None:
item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index)
while item is not None: