From b36caa14b8bf054fb2eed5f312520369d7d1047d Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 31 May 2026 06:24:23 -0700 Subject: [PATCH 01/10] Add structured event schema and configurable event styles --- README.md | 31 +++++++- drawcal.png | Bin 10205 -> 9368 bytes events.json | 38 ++++----- lib/drawcal/cli.py | 23 ++++-- lib/drawcal/drawlib.py | 119 ++++++++++++++++++++++------ lib/drawcal/events.py | 25 +----- lib/drawcal/models.py | 175 +++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 24 ++++++ tests/test_events.py | 85 ++++++++++++++++++++ tests/test_render.py | 38 +++++++++ 10 files changed, 482 insertions(+), 76 deletions(-) create mode 100644 lib/drawcal/models.py create mode 100644 tests/test_cli.py diff --git a/README.md b/README.md index 61e2a32..fa7a49e 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ Python: ## Events format -`drawcal` expects a JSON file containing a list of events, where each event is a -list of dates in `M/D/YYYY` format: +`drawcal` accepts either the legacy list-of-dates format or a richer event +object format. + +Legacy format: ```json [ @@ -39,3 +41,28 @@ list of dates in `M/D/YYYY` format: ["3/14/2025", "3/15/2025"] ] ``` + +Object format: + +```json +[ + { + "start_date": "3/1/2025", + "end_date": "3/3/2025", + "color": "#ee2233", + "style": "rounded", + "markers": ["3/3/2025"] + }, + { + "start_date": "3/14/2025", + "end_date": "3/15/2025", + "style": "filled", + "markers": ["3/14/2025", "3/15/2025"] + } +] +``` + +Supported `style` values are `filled`, `rounded`, and `diagonal`. Use +`markers` to draw green marker indicators on specific dates within the event +range. The new object schema is forward-looking; legacy date lists remain +supported for backward compatibility. diff --git a/drawcal.png b/drawcal.png index 62d0cd90a523543d18ad74dbda532982b8b05e77..29738bea903ac8f2bf05020b4b178713eb7c458c 100644 GIT binary patch literal 9368 zcma)i1yoeu+x38S$bd8mLr6)@fJiFcA}AnTgLEUEGNhy;G14L+sUyu00!m1WfJjJ4 zNrNEGcjov1zuy~cee3;L;$6(#d(XMgd7i!Zv(Jsw)ls7)VH z5P|;_Pfx5M5N=BiWd(iz?9H42UDJt~zis$JB-|+<)uEr9MB_z?SVSbR)?E`}bf7uC zmtCik$}^zNdoQa-gXi8%@qJnMg*WK_8nK~K*?@=jjx&wuMH_g;OX(SyL29BCa4v6g=dKI7qJczzEv$m?B}4Fcg%4_`jWm!j8i< zG&EBBmX7xGE@jtF)Yt+Dyc$_!u3q1ZZJl(9HWTjNUFp}UvC9eY((!qcrGLG*{QS>5bmtR|K8mLUgp0s;al+1Uv(?4gXziCuLAGTY54 zF14h&{h_;R*47+<0_Adp_j(;C-d0p7+uCvm1_o|iR2o;?btU@=dpu&XT_3OH6BMjD zjV25Sms$0t3su)VO7-4fzDG+>zpz>VQx(h{A0K~bImc3D-(yD$@gf`PVJB-P zJ-xjcQ{Usg#~a@e$3Y6(b|Y57pIrMn(>g>OrU`CnrhB$(2k@UUdqaD=OlHM}smj*qbgdEfL5T2Z#Vge7GrX)BxIARkLJ{4(Y6dNpdC9;53@!Jz=^k^)n#D{>J`OZ`v9{P$) zNZ3q%cwF~bPEOAM&xjf(^gIwm;&oje{m;vbzZkG?(+!@Tbu+!Gyq;@cZ?H-~A$d?r zn|${|vdXe8DV6vB>*i+GtlREgN!MkDCnoHJhQM`x&AVMJZ>y@hxRPZfR`SATtNK2D zZo52pcMxZu4!b;ugxA$cKnjZ)lNAf(wY2(bob(M1UxSBSTU!GU*||KB<$1ifVWQ z@5}BI&`x7b-lk??h{?}~oh>l5$v=E}?d<&Ab^Cbje%LdEa>MqC z2?mko9sGVfF_2)z)7v1Eam(Pp*UO6uKK6oqoSVz9RAEe3=Z+Y0$o>#*@lMUul%=Di z!(-tS>C21^@ftxpTj>?5uw*pab^##C^n%;K)utg{OBk|6TPpBMDF z(2ZA_lS3Gpj(xkll$9elFVoV}=Jt8i?EgY z2Cn9o7G6OX_a>RO)zy!Ft^3zO=^}r&hCoRO>U^xNts$z|IKNh2aq%Z6x3(g?7dd!% z>~t)~vPgSiM|D{T7pL3E(c*j8bTS{7=oAze6P4W0>%wKZ_li!YHV-7}{_@e0f98PQ zbaO!JWN2M&Ee9_zF%uKh(P-XK+Hb4W0K#Ign&LeHgXXlOF8cLzsMM2uX!DJ#p$9(u$9N`|*0qi^ZGNo9k_Jbr>I*RyBO zT#!&yyQt$mkP*ae{t%J2GsN%pb-1{AWOK7DA1&Fpi3ypO`{VZ1FQSJIbc81$F$C+{pP#ohz!e6rg#RCF6VbG(T^+w z2>JQ>`2+;KjP^UJ?&5$;!fiVCb36lbd$y&guTSa8lbX@f`moE;Z{NPnzwrG$qW(pEU!j=V6?}d63H5orQ8Xg{aMli2{ zqmiehNT@J~R1%xqv*l$w|DYf)IGnn%vGE%I6*GmB(fJ&|Sp|Tyxj|b}Sf9Mm6Miys za*XqYu<6dz@?d~LZ_CPFJ;}iy{N0)%2P>A4KmjTARNQVo6=w6F!*lsjL;7)*;&c6M zAo;tt3+dqCV6*WB>7=b zfX^>ZCfXKSPq#WB-j|C4@XjjZOA!zd;Qza{B-6n^Ai&zn3L>3z6%P*&qFU`xYa*0- z@^Q6StP2!OVmDh51so37b`r5@W*it8kU)VIfr*MjYinyIP~x|4&4b7C>|=%e^_2Y5 z;}!GBGK9{nF*{%eE%~m&$_}Jqcx&sO3bRHU#R4e!2g35b+MWuesoT%i#LgJ1#g)64 zQjq+D0z9=O)^>7{=DC+THa469mRx$d3=9lnlaf@C$U@gFKs?#fK4f5`BO@aKd$8v8 zY31VL;tf8y3GxXG*R@oYmbU+>b>3adu(a{_X9U9~CMFs+`(s?s0b#p3#>)6^gc}L^ zo=S#~h@^|O9^n@i6{TnveQ)&U)fb>k{ei?i(02@SZr6WxbMrGu+zxa;g6Uz-BhKd} zD}y;o3JQ2Q9%5vS-JGh&idP08G7}ZJX-GLaId|q`*cZN}-2_FEqRtH-GTEFp3Cn%s z#w1~RT1EyJKY#CGQ+YW#h&&+4c2EdJXzgeeF%@Fek2p;;3WSM~f+!&&A-}5XYHx2Z z2_>b9lF}8(cZ#nQ6EOfq8F8$GyDTg$j8)1ja(`v%rUVEy#Z;YZ=hq@Fw4<`B>T57z5b@U5RtHB%%;_@LbCC$3kPBWvDDv+uLAOs% zPBLpax8^oC6W_k&6Tf|%3kHLL(@9831yKrXUaT3Xg~p&ACuV`E90Pkxs} z!k<3HQ!jczK|#?_bepZ1h?*?w`+Msv5-#7v3ko=1zI-_cfQXouj8KeHL_~y}n>+vY zYkZK{xFj&weydYby-rR}fDZ_CfD{EOoT&f>3oZf9svBS>+4F7 zAKwT)D7Tz!X{)biP*6~C+wMN9V$K_i8FTU}pS4A%6c_WTbJNuS#-Cmpd#w`hadzYh z$g$G~BYHfQ5QA0#P@IyN=Z@|8wYZ1^b&XRREiKowR4yywa?dNObg=y7BoHW_h2xdH7@)WYvTl3+{>D#_y&68x$PK{B+}vE! zdzm6ICe`xutE&)@Ri1|%#u>E>3k&%p4#UI4Rx$-$fUz4u z29%YTFJ|^hK#AJ8W^uM_1u|Lz`?ATn0H4tqBve2Chy`RNPyoSd2s}mVP~~4 zd0};=5R|ZrzCLZP{{m@!G8Ln1&a@m6$O^tRO~A!i1!s_-;7ME;`9U3ShyKM%dM*$& zVX+1J0=p>ov+&*{t_``JVeLeStbJ#ouU)=D+t2G5>n2@t3cTz zaUI5RFw{mtY;j+~#B^$!hC8+wVAr0yIWr1M9&vI)RD5u_aeN*8=1lqYz&=HwqTpSi z`yeMvUxt9c@gr)bAP5qS{7&RQN65}g+z~#s`v!MZ@K7Y;SQc7ZqBFwSNEyDfkUHoR z#(i|1KA!OLFuZ2)acXF(k}cS!%uenH*><@ zP1dewuvOj%1y3VR=&Tazq=g`O6sOU<&hgdJ8-6<3uER&J&1lqgwS!=E(W zIXcyPp4*C#Fb>zMG3>X&{=ogQ6Q$-nxz#UlEC+rti^?d8g1WjlozkQ=UTemD3`FS_|UY3N-+E?3` z>FMxMM%2*CV(aO6L^skRuqL#HuIcA#N6l9;_P8;p&r#$V&tJX_-|?XPz0RVms~b6x zwQXMvT8H?C2BWF4kNL$Ccl|5k$G(1j z*4#WaJW&!e>7r_EOc;S;fRC+iY;-)m5;HtDMhPnar;* zm?;519w!$UxNoOEQdUfYNKb%nb?b2YsXBLPY@&HWdb;6qE!{J#$Vfgti_pXXSIFE! z@<*-P_-mKfm4|uc@N?P2{Zi9PqjgG9$;niMd2O*ncTVx`2eUgreuO+s+a&4Y#ud+;`jC}TG@LVQ%tnxxkLc$nmiT|jde~vHQ-RY$208Q2A z=5=l7pr9b+OjD5O*0f4O!h1Q#?zst5_(CUYo{74tE#+hOD&?gm3#4SLdb!IMQBYdS zG%})hH0bw%Bcsprpv?YG^41pHpTFzWbad^_br~59FH%!ImNf5NWxw+bz`md{O}ANh z@e1b4Pgt5J#?_R5Z=YOFBI8Y69UFUCFy!;+8$E-wWNe*J6-|C101Fggv026gGH{^- z)d_xnZLJpgLU-p!?>jqgn2z$C9FCUeW@^@E5|CUVvS6&?nHkE|-cQ=dgsv{0$e8lE zO*x;85o2i?89hDE>sb=6pAI*v0Fg5@2%FhfiAAeXa;6f(ZaR}!nf0olbPC5!5JQdLvq5OQV84LNj_J^Wq>KJ!1EEJ0tD-Hx;;vciMe+CPc=G&D%T#YK@- zA~WnD4usEv`sU!EFkc#3&yv^9aWFUcC#910&f(z{wLh}HbXwdr9ALP?Y$@Wz;u$Vx z5I!6xUYu{g8v(6IP0i!g-@ifMYFqp~<1c#i@O3LEI7D6d#^AB7h3?@~+-Yzua_Z7jqt8UuS`(_P%$Wq9-B1 z2R1Vf@s&jW<_X3+=aN+Z-oU~-m z(T!Jc+$iD`5>nFDg&9_A`ZW#eGg4du(u7UM8Zc*pVY!~JEw*7F3&qdXoE%@hAEarKZ8l-jaavi%+B{-XrN4j7z#vZgA)y+dqbWGO_+U)6pj1`Igvc`h%es*!RHFsitV0X4>GixsIvTFcXgJO z33gPmC8QVrSlKWunNXedOvFnl}P$~S&>@Nqj1RbUamVJJ}1l~(JJjPGakwF(Q+?ddVPQY$@j*i>Cj9z(_M zsbw$A=B$8|O9xJP4<5XDo`t34;^N}t_q;1MVS~x9s9>#kGq`UQdc0@A&CAPi+m8ic z2{4(~sB?PLu@A$7VL7U!Nubogw*^2P9vmEio?pfH(6HndFeWf|0|J8PuUzVbgNt_o zo`Ra^aapYDViZIqHj4l9V$#VyqrokDOVC{#r>h@PL?aH;IFvT}F87llt#o91dcl`% z)PWox+oOK^tu^VY5I4=;xBAwg5Y=@I3o)3u#-zffs&DL!qA*#r#|%#xz{Q@Pq<4;g z!9cGwJUK}U@H-_1T5@X(yKMoaGq5Q|pgmL+avW*oJ@BBEF_+F0Sk3l`_)7pv*k%BP0q=f&8Eq-@E=%MuJtJ{iMHIr~L&wUF@;40Fd~=sB`*_$d>V&bxZ^k7(4*+zV7(G zU)=W%z-cRqEWptb=ciPPY^1t}1c13}&n@|A6^kBNPkt!kyLnU1 z+`L7tcK!En9Ed0uq!_z6KY2n6=97|M*I&0WX`k11aZX&~GHLd?FP@k!x%&?(l1)tA z{oPdt5erqjF4}11?=rxNy!BB=(W=}^;E>Ra3coimTNmKQ=v?*8Ns2*E9VDz?6SmvR| zoK$RW323felf6L<+}v7cFyKgwtW z$dVCz%U{22=aa+E!ZoUPgTII`+iXaF_)sU$O;b?0DFIlaTfcLSDXOy4bzh>yr^@mD zQCe`pGGnz(mpv_wOTMZ4GO=xnaduZMM? z0TCMmK6U?Rr{M8E4LP%4bwEz**qaYuYXh!$Q)`-99`zqRbYsxeS^wMmH?#W|z2e2T z;#ZF-tQUo%5HZ|*OB$6TMPlDjPNBFA%U-I2lb})H+YXrR)eZytW`!QsNEtx~+&O-` zJ=uSH5&BoIEH!Mm`jAk(EG#KuSQ)aEf0VKQT-48ro3|VX1Xx$FlknbQgfnA5fOqI} z>~%u}Em0>7DA4ydDz8j6gi;;fe-UTP+hw7pjitMpuEe=}x-9v9iP3w_<2rBr$>@kL zaj{TrA%%gvySvNe2h!W_vmM>+?DF^S2_eKL;A0KGn@vJ_1_iqEg7iv)^w>WIGGBhy z{!9I7XvFml%n$()_C<4IFANj|7;50iwJVwu?Z4B`3RbsgNTkNG`U`yd>%6oM{XxlB zJG_S|$uGJsE4jL>PeMSCLdKIy3jB)hl{_pxLe_wOqsqK3vey={^%E)ie!YvWxj7h+ z)g7G%xH3sd00D4re4iw4^q4s&lJ9w1EfY-~`%&@ZlY!4l)!yOs= z^rD@ttYAdM#Nb>)(8{lFd|TLC zn}St-c4>48A`#ovL=Byz3J*!ba9s~KPsbFVjGC_qXq!USo|*$&2@W5@JzYOp?}yl>0QLO}#n#$;1b zg?GaBHOR(?gzIB@gVKJP&&!Pr%L$q9$p9se!vKAKf+)`KT<|waN=l$h*7mmR29jD? z<+BfP1gU|biOtM(Fl+F5UP`pCy$F1fY`GvjU^3|n&_QHVk+SHkS`EL09!wQZHX}jo zNvNpU>mxUNWiGt7c6Q#I|9yHRC&wI|oYJR;H6SJ3Z3x8?o|1MOzwu+{^@BjLkHAz~ z%1=Uhet69J&t%_6l(&}D+P@)mbaQ*!)<#Q8ss)3Rq<~Jw)wLe|$sy@>cTW$AAbtGQ zxq})|`-+7l@<1zrp1{C>5@;pufsv{5&fUe5{0W>F{mZL(cV|a*Fb5k8n$2rEtWlhd zz>*dcssv>0d$T)JD~8bQK(3OqUzglQoY`~lSB4Q*;)?Tlg{oQ%z5S5%WpljDWP@kR zjC1^Fe75A3LvPaS)U2!&z(WNu0SM~mz7zIGVUBq92(bx;Pr@i z`wLw6n=jjTbp5w%66f_YnK@4j7Pj4g5xsKDE?9NmEZmKQ!|qvtJrqFi*KdOGR_yjj=Fn?EZ9w#@P0_8|YqM&+o~nweB7kST8~nn_c8 ze;LlYfFzGVq1qRBfy(^1YQge1*CT@770Cal{ zBNMloy)vF-h@5<0yG{X-&3b1eSd%-0sGJq4mZ#?9L#e6N$Lo>@RE!NKGmhw^Fjxna z0jPsaCfi`0vgBlviFZHcK?}Im7A6PufhX8XtT3&oZ*8?PYYnC|Fc{N`p*}h};RGJ> zO~W_yzj{SRMn|u-SR`+3R380G4TCV=JltipBb zUtMa+Kzk899hse8N5R3N`D=yY^@ly42M^K_ql|rUiB*hIit)#oZPx~3rrV<^TU8U1q^FN#n!}N;~EEQ zaIPuit7<@soFdy|z#6ayYQWI&>D-)3-2uWF(uAGq3tReXm4=j^nf?J{TvJ!aaqCu8 zDK5yul!j7WX8wYQ-FiA@?N;(gq!-*6U zO5Zan^3is5cN4Qn*eaIl=L78t$RSc^rFjykN#JbBg+qnSXUs2N(4as4N|>-hBRAAL zA4+vC?)JcD^mLp+2dk5JK@}Yx4W*@BO(*(q0%yEdQ%mdf@jkSw-^|CcF3MQDL94RA zfi|5jSd_uZhZB_doYzA>C<&CB8n0HGl#Y(h!No-pq%Y8};;*Jww|vjrWs$(tb(u8kDWdA_`=xI4*&6W=HeG!!7QeA-a z2I09B#Y7|P2a;hIoM1l-jJ4%~1(P5mjxl@2Mn@I};II3`V=d3~6Cbb(NB+@acH?6> z-sYx>vWm*n?#20UrSEpC^UeuELkHzN)p^L}4ae!t8eb&;_#quL6%erdadCGeB7bIV zc^TAwT(yo49jB25RgTvdtgGTYL6~_Jl(x*a47jfL>wk09^M_2fzW>lY4R}$C_ z0g(arM7Hz+9dLnw32D}5L5kL1{xeMj_&72u@s4msem)eqOLabA=K^dftNQrdVrFI* zMIa(7Dh7_WFGJa{T)8qj4hW&=%Z~^kbMqS;C*90}=JMS-y9lGMlvDC|%G8zx`!SxaKjp9Z?jsWcdU+(yqN)vlA8aVgyu51}R z=j`Wk{%*^*oP@i#x4q(GRsS-uG9&)Cw>SRjHR)|7wzWkc6$+Eqfht}cKV7kv5Xou& zWcIY{|NW}RKQQ}ITr%lq*rWf_ERM_(iik{{N#$h!+}k4qxBv_YY>3&X8S-j++u#c~ z9UXemRF;*M;X#1;njvDT;^jr-?z~6|QYXbxX z#vD0GF;x%vewwF`>PFMUQUwEDoawXjnCR$;Ai5x5HT-z!$8uH6(ib~bQ~J3R#05?x z)lNTab?na;tJR@Ds+1F6QVE99^uWYrpCXA9Q$2Y)QzOTavT-3Z5FOo1$LWmHbJVC` z+NjsdZ{D2YY&OI=dmrP8E%T-f)#%o;>Mfxr2;o(?2yStw2 zF*19FF-b`>s;Yz&6BB8ET~U<&3sDzl%Zc1Q;iQ~BJw1OE8nHmi(?eXd9+D4MTbb;#QD*@wqTr9Y2Hv90)$;kzM{(L+nc<69>WEe@tKl&5$ z$1EDGeW^#Hsi4*rd-d>exYlv)a_48M0QBLcpEX6;+bwAymyGXs05T>ChY4D1Yimwk z-pTo#J>vJN-4Uy*q7P*O;0bYXxPq^*a8*^6(CGkYM`tHK1;tR2W(8R6uW=z@rV6t` z8X+M#KCSfh-Y7i-gR-u!qK{9bX^IYuOo8g-e|B~(*x1&s z6N12~luk!Y-5TjW+EizX{glmmvIw?N)Stxad@#=+jQwKLvSDY(>Y(Mp@1XG%#=*hy zl9_q^6Doy4XBZJ&$aA!Oq1twCy2W1<>~?2&_ZL)3pJ)O`*e}~T9w#TKWFA`xCNcAy z{IMrT!~XYiNqSzPoaRFgOTHIn9>+%V8u`3FH~Tg52?>rb;k7bEk7*UOfPVgRpaIhN0UdpKEfFPN#F`gKHT5Q!C(x$yh*+{=f{)U_V|>PN(Kg0 zEmwo~eE#=BdU|>Yw3#Ym^24t6gJ9&&c?t%($Dw|K8{1Rbw0X^fXhq!<|_t-^z`&(1%_~G4G9gcvn<`KgBV6rHfSC;p5mzF!>YW>ZI+tT0s@}89nANJ z5ixzJb)YUTE`D>t=pruu6eaKzxJa(L^f{~E<4^4y9VYHtthru2tDx-lG12A1=o(Ssd;h4DZ#ds891+ujFd zW#td$Cg{1jx$#L!6(+sUAP|VhNiXC6qPhEWA}?B{?x6DP1ch%rgFDyTWBC;>+sf&_ z=bA>w#+ubOr1D8D^xWJD0|NtgyUh>xH~Yje-uOhpQ>J%w?1q*V6E(Q!BBMSbwwT%-Njs-U1CHt--2 z&|RM~pHG*YVv320xgAyrQW4aBc0@E3xI)PPmc%PQdavRVc znxy87UOHHJ42C`Zgo*!{It(sII*3h8O>Jf?Lt|r~5n>=q1Y;Qv!THrC5chI{>rLr- zNA&c_J|+eitqTCIu`w~NCma3H71YR+l`oXB85#DjX%%N0#adEMP8HgLe z1&d@kp8xl`<8)uIQ#^7RgfyJK{&tjhdL*E zucmb12W5usD1LXl`iKtadzw6U^F3oqqP9sx(bN(Ekg9T`*0;6@$jJxmoVS?g);BiF zd~e*WtgS;MBhBt_uP(2z1$@KJhtpkmNQ{h(_`Lr8*f<2S#{h%(M3M`F7Zod3e?)lT zS2r3UhuxH zW>r&Dv#8QaWYvcOe9`y6t1_t)1e??r|4QX@qSVi{hX4E4>CWxdDVdN*wEx3}{}!(Q zC1Pl3=-zytO8H%q3)acyT2zJqgC7Sc=S;05?fw0IPHrx{%injsTmV&n6cy!ER}-_c zvc8p<7rfgN^gN*hcPAojYF3z)8FgZSh0$3`!NoG)v040~di(b6(dlV>e?O+EsOak2 z+AC&eIu@45@^TIoY>1?^GzvRAJMrl-oIeh5p~nQNIXG7S93WqgTpWU|q9VPp@Irx? zedDooe#=}pFUVCe8I&1;Qb0;P59+oMENpB#K>qFOlFt7pKvN(#Zp9tPsAP7s-pj?y z8<&vKxs~A$b8z@&0&%K?cXUWPIXeR~L(9Wct)gmSYipa|{OfIQ+uYnu9vfPvYXw;D zOIFsJWIhp*^uceu&fBB(;t~??3slD^CIElx_P^iqck}X9RK))D=@aZwrn@9nxuC;@ zgI{fUmSi1R)q)F>zZ0;3TIm09Z!=T=8Q^8ObT-|gq6{6UteF|Z7hK9}9mA7<|4iC~ z&`nHC9Ij4GZ*Ok}I*r_t7$;r;2%m29=520nP8`U01DQ=A7qhy$dUSd^q^72(-D|(c zgd;zSnt>rWP0*btsUI46_6ZSHSxrsK&#&3^Hgm*+gxv@U^Er!)SBpK})F#=Sywa8$ zK>4kqv`26Nt^rFE=se$_%bj+8)5hKd0t@WuI&jncyu9GeO*3Gig2F0qSFWLyQV&vyf0k(G993V~o_HCx#m5G{~I#$OpW|gF^ zy&ZUber4r2M|6DuhNO&)^WQ(ztE;Qb-H$BP&rbwIJFQIQ(X4f;Ywx0~JBX&#)YPM+ zqu>4gU&F#MK(-8RHHcQwFDLE*XBg8x0&M51kMAy)jJY7_xVT#*GL%&=wq^s#t>ziN z!Qz<)qd9VE=?R9ouM@%mTULFomUQm{m`@!@Co`3n1R!#;AY36|D7<>M*8;02duNQJ z`!_&-FkmAQz}8w@TjO`$3^x_p0CaWO@0_#RZsDz(8WDISq1&VO*l^M7XToV3AjRZ< z|Nc=!&-3&5?^KV}aCB@8s!hWJn~% zdThNB7L5aD*w^8=TJ{@#Q<`-ruKBwl&CKJx3LlAN3;A6fJxT`34hPyC9@NM4NE*qA zpenKZ_;cV%8&!ZvB-0=`KK-N!PWmRo;X2Y0kltEHW3R<&vBy(5*(0)UZaDc$8Pqg1 z^SN5FusR~N64aCwO%f~=A79@(C#m`#M(Yq_$v;~lybRld9O0t=dZROelX%dO@53uL zKhlN4r~jkm2@vW3J3^~lLXAc9w<4T`9ZoC5!RPWfq@U}*CDS!mf0F3o==I|wqnH$F z9^X^P^s%;@TgHfcd=-p>|C|-f;R=;iC!FW1rO_RkF_CJ(eyl0-k&pv+Ln~$)-Ifm0 zlg#f{{snwP)bMBhGgW&4?I~2FR8~=0i?>j^yt%=|M(4`mkas-RO!_R}pd}Hcfmz=< z5yMyWw2jIxk}cFZf%)pS|p``%gXiyJ;;3SkfVHu}QZCFe(d$OyB`3dI>P^*o;UAlM!`4V~Fe zh=>n~L@?d*%1Ubj)5hLHLsWgemmGI6mYm7ukt)auFJHelTWUrmiIA4#o<+yRG~O9+ zxZDz*lGXEW3p%D_#4WqkegD1}kdplp<9@O}aoW_W)c1rCc4;Py51XOXdLx$KvUX^` zG-#U-5jyOBfF*4>Z|01Y>Wen)y2^NZ^260ifjb;R|eRRLRN1Nl?h9W!W`KNYtYe_9b_9Jp=AnUg3<6 zvBO#*EhR;t;a8v7*Z2CLmvCq278({-v}$(RNVbgA@hW}NILgt*MRsA~YyqTN3s)`p ziE$8o(_1yU$%G54>TZqms$PqTC_$&zA!emrC9TjIs;H<4tc`*b{w7x$pNL4#G}rm? z8?!3T-igU*9L>o*Z7?1+Ud`Xjk8mD5A_-w8*6>8!g8vJFpz7O{9bsU z@3q^w4Wj2xT!TuB&x`9Tx3x%iVIdR>{lSbpBpiin8h^-`T)U4W$Vxsi`HHaF=eJY%uxU9z$64o5jJ4$V8d|()Sf86AJA` z!ILX*|HY{#H<|wRI|$tg!q?2!y(l~nbKV-_bTB{y)zMbOeoj_a!2CRcLYlzmtJ6bJ zgr|aXL#|{jQvR{>?;c1Qot!E6WI&0*ElN6XB5*9U=Jb9;>0s<>wJGapE{t_ck4-YoyO_$~eKU`DOB;720a^VmVAt(Da4Hgy_1|1=Ws_N>X z6rvxKRnj-VTIiN(zrAw4x-_5& zS!^c6Tm3yFuWi>?xHvmLxhc1ad1`d@t%L;NN28IMv*=h@RH6@98yiFxRri1huWd{W zn?^}Gq=*IMD2io|u$d1P!TKMLqm%&aNRZ5~y=49mu~L))UZk$x#k=IgD7U{WcjGt! z=O@R-#RYYB$HmfNS;J63LiUH6`Div&UJk-w@U3Ul#>_JD0)!_584$WMv{X|HQ>00hf>#zzgb&` z7C7I5<~;8aBxa`^#BYZKj^f}*@t>4a`%gU%@SNBwZjF zJtH6hb#p&c&%l6q{un+!{ty7fnEtdIN(062Nhg^dri9neo~=R;72mu;05u_?RkwF{ z=;-KX7YD*P{z`0i^CYR?&P>ep2L}@b#->xH1(i)9+GEkL$7%(2CIh&*xE%ie!3Rkx znUfJ4&~x>~)08?8H+y?zundtg)2mO2Uu5OvjE3M`TH4zDo+t70E=5rHJiqQY^)*ZP zoS<9^hj0e~#Ysg+M?p-C8i-lHv?Ty(_PhN6TxPqn(KzooKLe;sGGnGM%(-V{XvU@! zrM>fxK{712n)ni%il}1|U0%)<6da64A%c+bR_g6r4gf{TX#>Vq*3_vXVLR|y_l>vw z0R8asSAbQNm1Ftc4;t467NG7qe#Y66pAcyUkQcUH1TZi#E~i>VvOygK*cU+M%*;$T z>v_j+TMutx2Z$9Y~iuXhC??30)qW_zS6H`d;2sWV~3pihYvoC5g*EWyp zYoeJMewL;tAwIs4@Nk8<@~n7dWF5zAjG^J-4k_tB-@POZ`ck8twb=6TQ9j+Bh9kl| zsvip=w2Y1pR)gDqs|g24oW0T1p(U{r1*#m5t4NU)gR4C;3LvWk{muzIBUxB5qOozz z#ME@GG~@a4@v$gNMz8+EbqQTv7tL7h#k6qt5g@z1c3JQB$9`?86S$`gtiQ%KHzy3! zR{W2yJ|7ff68jC+F!~#zVeB_Va3nE|eG$dhqZrB78zJ=={t8YY{I_Y*;^s*tdF&~( zvgJA|=XV2pHi>Gvhs;iA7XsEd_<8J?&175*=i_$o=0Z;s`=Uhpv zu-aUk^VJk8!A8sz~E9!t=hApez)WV_DqilU@;VLe~7F=HwD!91T;5hPwL7))uC0hVgtF zj%pyq%1Q|)GXz=$@J2^3V8T@XeZb`?xVG71+Fd23A>Rj#Du91%J z0#v$*>~DY`@r}op1_~W4aUF8dEinRs+SkXTqT`b|IjIhW9MD^ElEbp}@nHri6WgU! zo!!T?dZx$8?SGNyl#e^;taiyV@yP{Rzn;mGeb$B%*ABr4CS+0cMei2qpab^>g@nj6ioS0A!%KYyoB63`pQ~3=HR9X@ojS z#5p-Rg!=lF&j?Vnl?zCOy$FFw9!n=(K?}eXRI)nfZ-^wqo=%7%78*;N44Pg0E(WlF zh?HYW$VQFPk~L3=l6wZ4)HNEEhnZfpPwLAR{?vDRC(eK=+D63;%)%*{nkgPBk5 z5*Lwm)^98to=d~PPHoRKwQ%lMh^@BsoEWBrj>ct@U-JXsM=9J z5Y1U9cY%b)c$$5Fh^mcDBSoErmLoW=qqytwzwHQq0h(2n;x%;?{7S=8{#ks6?XVnO zW^Migz!r?AN_4rnxgXWZ&B1S5f8ZOs4Q`G{%VI!H3JD1@SZwq}LPmDQz!an;`yDJt zt9d@Rl=XtQ-41&!TeO|apV&;F(xh7{(_{Z3TDKPIqhs$wyURmUqOEB@#ygSo${fgM ztwnf$Mk|>hn&yZnA=8qyTb?_kkBqa&okY}@Msd&107)znm(W%yyAG_S3hMEo@0>KH zUOe_L$d|OeTI4hCQ{hY5((aIE&Ns+wqOON~#1rhfce!^jPWG1!eU5qEtbgE=9!-zD-Op(HFU*CP!H_ z0o4Xx$R>Mx=K9l<2*Ec~LAR&aFQ4BPYPbqkQ$W|^RUhO7*>xgkRHVlODSn)U>xv(! z@=K3ecuZh~$zAhUpx1p^Wb6iTqToTg3HJwOF#> zDP35L-}Je=0wLG#`y#!-o%Ao!X#?GtJD51G8}$=^Jd9KQ7*8VR-VgdAXfBe5v))Y> ztVQedWM1hDM<}@-JTNIi-otq_Gd6Cqe@yvp+D0`d4*_kqOP;k!Qce!NX6ay>mbHE? zBqD;Il{E^41*kHX{BDZr_q1Qr@jiNmVSDiL>Q`0{*Bw=mYxW&fKIrNW7OMSB7V#Ab zM8%b6NvaU#4-R^Q|8H2}Q^65@tlN+RFYI`@l;)uywI|Z#2~|ITsMYLUj}uHh8>dr{ z(ujg2lAlhN16xZw#*|#9*;@Zwm-oYOI2>(J56AK)OUjYAw9J|cMP=lH&-)H%878Rv zPd34ATQIDkBT&-H!8oSixK^v->c_92#*mRO`59kB|20yN2m!H{#F@!xyZ z52~z9S4+3&JyShn_t?%-rls5i?-ALXkG%;WZbZs`dD7XlR6mMG4t&xsY#579(ChHg z{=#U=e>u*G(8*8G-0#9AvM<-)Q9@a@^Zip7{ZN|1g6}41e$j*eN=QgG%is0hxS2X< zMr`af%BJOTl;1ruw4g|)Lm(m|0tsGQIZ$>oV7gCE*zcCl|9UI~NK_oar$A$Y!{9eU z!{A5>yZeC-=tu|%bL@rM-gw&W^9t3Rzet}ie~z0SlazQ*Jd?cbmV&5H8E*f8-}&=l zu9GaU!LB>H8N*qJ%c7C}{#rDeeDHH2q#?3BG1$i+W%Gzl!P#8A#g8OICy05Cgll%H zG;t#3*jrtuz1&ZIkoqTYcCA4h-j+vJL-DW>2S@Dftxwq3If}t*kSKvR_s4=O)>5b5 zC3H&PKkeki3Apx5qbH|vSGeP)M-jCqrV$e(Xq|rsb#(<8$^nxqA(4@rpq=XW!T4-v zA}z56K&J&JC*2jp!sYL$1DUNP_vh028-)#i0k;iJY%ia!X+KPy-$_aH^btKk3s9B3Nk+h&<((F1qbL< zH|$&lEpTG6>ePCSZC1{jMo%NWbh)L@;{6$%y_DtNk;V}$#Z&s7V5x&RG~5u~kts0b zYTB01>X~6=No|7Io8%mFZ@>c#$!j>#(BdkAeiOsK$7jcDSV&$_Vs+-)910cJPNwDv zChxGte%eOoP{mCo?SNIYXXLuBeqguI*A0^QLO|u%v83Y#u~f3SmLyNfbD5{Xa&ha%xhi`<9?AEM`D79UmW$1_=+$bCC6I z?#tz;6>Hb5ZftY{UriHcZiR-MKtqae7XWLWEYfWMw>|ba9{@%aK&>P1M2-;H`Wqw; zvc7xJ)R5u`e0(lwD6+9d$Hx-_Y4H&$fI6O_gKDr)?+RMj97Y{za*^^5)@$W|Mnc+2EwzY(mJo~)_M!huQC@xx%=Scc2`M=2(p>EyfblHQw?|KSYudYHYiYUt zZvZ7%IaGlmyQ*qpK~qBSRGp>FuJbK5?J0;>H#JhXEr+a diff --git a/events.json b/events.json index 4689d6b..18c9b69 100644 --- a/events.json +++ b/events.json @@ -1,22 +1,18 @@ [ - [ - "3/1/2025", - "3/2/2025", - "3/3/2025", - "3/4/2025", - "3/5/2025" - ], - [ - "3/12/2025", - "3/13/2025", - "3/14/2025", - "3/15/2025", - "3/16/2025", - "3/17/2025" - ], - [ - "3/24/2025", - "3/25/2025", - "3/26/2025" - ] -] \ No newline at end of file + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "markers": ["3/5/2025"], + "style": "rounded" + }, + { + "start_date": "3/12/2025", + "end_date": "3/17/2025", + "style": "diagonal" + }, + { + "start_date": "3/24/2025", + "end_date": "3/26/2025", + "style": "filled" + } +] diff --git a/lib/drawcal/cli.py b/lib/drawcal/cli.py index 7f3d4b0..fcf81cb 100755 --- a/lib/drawcal/cli.py +++ b/lib/drawcal/cli.py @@ -97,14 +97,27 @@ def main(): args = parse_args() - if args.events: - events = read_events(args.events) - else: - events = get_events(args.month, args.year) + try: + if args.events: + events = read_events(args.events) + else: + events = get_events(args.month, args.year) + except (OSError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 from drawcal.drawlib import draw_calendar - draw_calendar(month=args.month, year=args.year, events=events, outfile=args.outfile) + try: + draw_calendar( + month=args.month, + year=args.year, + events=events, + outfile=args.outfile, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 return 0 diff --git a/lib/drawcal/drawlib.py b/lib/drawcal/drawlib.py index da35ef7..0dcc6f3 100644 --- a/lib/drawcal/drawlib.py +++ b/lib/drawcal/drawlib.py @@ -38,7 +38,7 @@ from PIL import Image, ImageFont, ImageDraw from drawcal import config -from drawcal.events import validate_events +from drawcal.models import normalize_events # set some global date values _d = datetime.today() @@ -76,6 +76,51 @@ def _text_size(draw, text, font): return right - left, bottom - top +def _draw_event_segment(draw, x1, y1, color, style, is_start, is_end): + """Draw one event cell using the configured cap style.""" + + top = y1 - 1 + bottom = y1 + 25 + left = x1 + right = x1 + 25 + center = x1 + 12 + + if style == "filled": + draw.line((left, y1 + 12, right - 1, y1 + 12), width=27, fill=color) + return + + if style == "diagonal": + if is_start and is_end: + draw.polygon( + [(center, top), (right, y1 + 12), (center, bottom), (left, y1 + 12)], + fill=color, + ) + return + if is_start: + draw.polygon( + [(left, bottom), (right, top), (right, bottom)], + fill=color, + ) + return + if is_end: + draw.polygon( + [(left, top), (left, bottom), (right, top)], + fill=color, + ) + return + draw.line((left, y1 + 12, right - 1, y1 + 12), width=27, fill=color) + return + + if is_start and is_end: + draw.ellipse((left, top, right, bottom), fill=color) + elif is_start: + draw.pieslice((x1 + 13, top, x1 + 39, bottom), 90, 270, fill=color) + elif is_end: + draw.pieslice((x1 - 13, top, x1 + 13, bottom), 270, 90, fill=color) + else: + draw.line((left, y1 + 12, right - 1, y1 + 12), width=27, fill=color) + + def draw_calendar( month=today.month, year=today.year, @@ -115,9 +160,9 @@ def draw_calendar( # make sure events is a list if events is None: - events = [] + normalized_events = [] else: - validate_events(events) + normalized_events = normalize_events(events) # categorize and track dates conflict_dates = set() @@ -195,6 +240,7 @@ def draw_calendar( event_color = colors.occupied checkin = False checkout = False + marker = False occupied = False past_date = False conflict = False @@ -224,23 +270,31 @@ def draw_calendar( past_date = True # iterate over calendar events (date format: mm/dd/yyyy) - for event in events: - if not event: - continue - + for event in normalized_events: # change event color of past dates if past_date: event_color = colors.past - first_day = event[0] - last_day = event[-1] - - try: - checkout_date = datetime.strptime(last_day, "%m/%d/%Y") + delta - checkout_day = f"{checkout_date.month}/{checkout_date.day}/{checkout_date.year}" - except ValueError: - print("invalid date!", event) - continue + event_dates = event.dates + first_day = event.start_date_str + last_day = event.end_date_str + is_explicit_end = (not event.legacy) and curr_day == last_day + is_explicit_start = curr_day == first_day + marker_days = set() + if event.markers: + marker_days = { + f"{marker.month}/{marker.day}/{marker.year}" + for marker in event.markers + } + + checkout_day = None + if event.legacy: + try: + checkout_date = datetime.strptime(last_day, "%m/%d/%Y") + delta + checkout_day = f"{checkout_date.month}/{checkout_date.day}/{checkout_date.year}" + except ValueError: + print("invalid date!", event) + continue # handle each day in event if first_day == curr_day: @@ -259,15 +313,21 @@ def draw_calendar( conflict_dates.add(curr_day) event_color = colors.conflict - draw.pieslice( - (x1 + 13, y1 - 1, x1 + 39, y1 + 25), 90, 270, fill=event_color + _draw_event_segment( + draw, + x1, + y1, + event_color, + event.style, + is_explicit_start, + is_explicit_end, ) # track checkin nights checkin_dates.add(curr_day) # check-out - elif curr_day == checkout_day: + elif checkout_day and curr_day == checkout_day: checkout = True text_color = colors.border if today_str == checkout_day: @@ -289,7 +349,7 @@ def draw_calendar( checkout_dates.add(curr_day) # occupied - elif curr_day in event: + elif curr_day in event_dates: occupied = True text_color = colors.border @@ -302,15 +362,22 @@ def draw_calendar( conflict_dates.add(curr_day) event_color = colors.conflict - draw.line( - (x1 + s, y1 + offset, x1 + e - 1, y1 + offset), - width=27, - fill=event_color, + _draw_event_segment( + draw, + x1, + y1, + event_color, + event.style, + is_explicit_start, + is_explicit_end, ) # track occupied dates occupied_dates.add(curr_day) + if curr_day in marker_days: + marker = True + # draw vertical lines between days if i > 1: fill_color = colors.other @@ -326,7 +393,7 @@ def draw_calendar( # end draw events # add a green circle on checkout dates - if checkout and do_highlights: + if (checkout and do_highlights) or marker: draw.ellipse( (x1 + 3, y1 + 3, x1 + 22, y1 + 22), fill=colors.highlight, @@ -346,7 +413,7 @@ def draw_calendar( text_color = colors.past_text else: text_color = colors.text - if checkout and do_highlights: + if (checkout and do_highlights) or marker: text_color = colors.checkout_text elif occupied: text_color = colors.occupied_text diff --git a/lib/drawcal/events.py b/lib/drawcal/events.py index 4ae8a89..4e6e9b5 100644 --- a/lib/drawcal/events.py +++ b/lib/drawcal/events.py @@ -37,6 +37,8 @@ from calendar import monthrange from datetime import datetime, timedelta +from drawcal.models import normalize_events + d = datetime.today() today_str = f"{d.month}/{d.day}/{d.year}" today = datetime.strptime(today_str, "%m/%d/%Y") @@ -45,28 +47,7 @@ def validate_events(events): """Validate drawcal event payloads.""" - - if not isinstance(events, list): - raise ValueError("events must be a list of event lists") - - for event in events: - if not isinstance(event, list): - raise ValueError("each event must be a list of date strings") - - parsed_days = [] - for day in event: - if not isinstance(day, str): - raise ValueError("each event date must be a string in M/D/YYYY format") - try: - parsed_days.append(datetime.strptime(day, "%m/%d/%Y")) - except (TypeError, ValueError) as exc: - raise ValueError(f"invalid event date: {day}") from exc - - for previous, current in zip(parsed_days, parsed_days[1:]): - if current <= previous: - raise ValueError("event dates must be in strictly increasing order") - if current - previous != delta: - raise ValueError("event dates must be consecutive with no gaps") + normalize_events(events) def get_events(month=today.month, year=today.year): diff --git a/lib/drawcal/models.py b/lib/drawcal/models.py new file mode 100644 index 0000000..1188d21 --- /dev/null +++ b/lib/drawcal/models.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# +# Copyright (c) 2022-2026, Bnbnotify +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# - Neither the name of the software nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional + +DATE_FORMAT = "%m/%d/%Y" +DEFAULT_STYLE = "rounded" +VALID_STYLES = {"filled", "rounded", "diagonal"} +_DAY = timedelta(days=1) + + +def parse_date(value: str, field_name: str) -> datetime: + """Parse an event date string.""" + + if not isinstance(value, str): + raise ValueError(f"{field_name} must be a string in M/D/YYYY format") + try: + return datetime.strptime(value, DATE_FORMAT) + except ValueError as exc: + raise ValueError(f"invalid {field_name}: {value}") from exc + + +def format_date(value: datetime) -> str: + """Return a drawcal date string without zero padding.""" + + return f"{value.month}/{value.day}/{value.year}" + + +@dataclass(frozen=True) +class Event: + start_date: datetime + end_date: datetime + color: Optional[str] = None + style: str = DEFAULT_STYLE + markers: Optional[List[datetime]] = None + legacy: bool = False + + def validate(self) -> "Event": + if self.end_date < self.start_date: + raise ValueError("end_date must be on or after start_date") + if self.style not in VALID_STYLES: + raise ValueError(f"style must be one of: {', '.join(sorted(VALID_STYLES))}") + if self.color is not None and not isinstance(self.color, str): + raise ValueError("color must be a string when provided") + if self.markers is not None: + if not isinstance(self.markers, list): + raise ValueError("markers must be a list of date strings") + for marker in self.markers: + if marker < self.start_date or marker > self.end_date: + raise ValueError("markers must fall within the event date range") + return self + + @property + def dates(self) -> List[str]: + dates = [] + current = self.start_date + while current <= self.end_date: + dates.append(format_date(current)) + current += _DAY + return dates + + @property + def start_date_str(self) -> str: + return format_date(self.start_date) + + @property + def end_date_str(self) -> str: + return format_date(self.end_date) + + def to_dict(self) -> Dict[str, Any]: + data = { + "start_date": self.start_date_str, + "end_date": self.end_date_str, + } + if self.color is not None: + data["color"] = self.color + if self.style != DEFAULT_STYLE: + data["style"] = self.style + if self.markers: + data["markers"] = [format_date(marker) for marker in self.markers] + return data + + +def _event_from_legacy_dates(value: List[str]) -> Event: + if not value: + raise ValueError("events may not be empty") + + parsed_days = [parse_date(day, "event date") for day in value] + + for previous, current in zip(parsed_days, parsed_days[1:]): + if current <= previous: + raise ValueError("event dates must be in strictly increasing order") + if current - previous != _DAY: + raise ValueError("event dates must be consecutive with no gaps") + + return Event( + start_date=parsed_days[0], + end_date=parsed_days[-1], + legacy=True, + ).validate() + + +def _event_from_mapping(value: Dict[str, Any]) -> Event: + unknown_keys = set(value) - {"start_date", "end_date", "color", "style", "markers"} + if unknown_keys: + keys = ", ".join(sorted(unknown_keys)) + raise ValueError(f"unsupported event field(s): {keys}") + + start_date = parse_date(value.get("start_date"), "start_date") + end_date = parse_date(value.get("end_date"), "end_date") + color = value.get("color") + style = value.get("style", DEFAULT_STYLE) + markers = value.get("markers") + if markers is not None: + if not isinstance(markers, list): + raise ValueError("markers must be a list of date strings") + markers = [parse_date(marker, "marker") for marker in markers] + + return Event( + start_date=start_date, + end_date=end_date, + color=color, + style=style, + markers=markers, + ).validate() + + +def normalize_event(value: Any) -> Event: + """Convert supported user payloads into a normalized Event.""" + + if isinstance(value, Event): + return value.validate() + if isinstance(value, list): + return _event_from_legacy_dates(value) + if isinstance(value, dict): + return _event_from_mapping(value) + raise ValueError("each event must be a legacy date list or an event dict") + + +def normalize_events(events: Iterable[Any]) -> List[Event]: + """Normalize a list of event payloads.""" + + if not isinstance(events, list): + raise ValueError("events must be a list") + return [normalize_event(event) for event in events] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4a6906e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,24 @@ +import io +import tempfile +import unittest +from contextlib import redirect_stderr +from unittest import mock + +from drawcal import cli + + +class CliTests(unittest.TestCase): + def test_main_reports_invalid_json_cleanly(self): + with tempfile.TemporaryDirectory() as tmpdir: + events_path = f"{tmpdir}/events.json" + with open(events_path, "w", encoding="utf-8") as fp: + fp.write("{not json}") + + stderr = io.StringIO() + with mock.patch( + "sys.argv", ["drawcal", "--events", events_path] + ), mock.patch("envstack.init"), redirect_stderr(stderr): + exit_code = cli.main() + + self.assertEqual(exit_code, 1) + self.assertIn("Error: invalid JSON in events file", stderr.getvalue()) diff --git a/tests/test_events.py b/tests/test_events.py index 558c4dc..34e3873 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -3,6 +3,7 @@ import unittest from datetime import datetime +from drawcal.models import Event, normalize_events from drawcal.events import get_events, read_events, validate_events @@ -37,6 +38,29 @@ def test_read_events_rejects_gapped_events(self): with self.assertRaisesRegex(ValueError, "consecutive with no gaps"): read_events(path) + def test_read_events_accepts_dict_events(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = f"{tmpdir}/events.json" + + with open(path, "w", encoding="utf-8") as fp: + json.dump( + [ + { + "start_date": "3/1/2025", + "end_date": "3/3/2025", + "color": "#ee2233", + "style": "rounded", + "markers": ["3/3/2025"], + } + ], + fp, + ) + + self.assertEqual( + read_events(path)[0]["start_date"], + "3/1/2025", + ) + class GetEventsTests(unittest.TestCase): def test_get_events_stays_within_month(self): @@ -54,3 +78,64 @@ class ValidateEventsTests(unittest.TestCase): def test_validate_events_rejects_gapped_direct_input(self): with self.assertRaisesRegex(ValueError, "consecutive with no gaps"): validate_events([["6/3/2022", "6/6/2022", "6/7/2022"]]) + + def test_validate_events_accepts_dict_events(self): + validate_events( + [ + { + "start_date": "3/14/2022", + "end_date": "3/17/2022", + "style": "filled", + "markers": ["3/14/2022", "3/17/2022"], + } + ] + ) + + +class NormalizeEventsTests(unittest.TestCase): + def test_normalize_events_supports_legacy_lists(self): + normalized = normalize_events([["3/14/2022", "3/15/2022", "3/16/2022"]]) + + self.assertEqual( + normalized, + [ + Event( + start_date=datetime(2022, 3, 14), + end_date=datetime(2022, 3, 16), + legacy=True, + ) + ], + ) + + def test_normalize_events_supports_dict_schema(self): + normalized = normalize_events( + [ + { + "start_date": "3/14/2022", + "end_date": "3/17/2022", + "color": "#ee2233", + "style": "rounded", + "markers": ["3/16/2022"], + } + ] + ) + + self.assertEqual( + normalized[0].dates, ["3/14/2022", "3/15/2022", "3/16/2022", "3/17/2022"] + ) + self.assertEqual(normalized[0].color, "#ee2233") + self.assertEqual(normalized[0].style, "rounded") + self.assertEqual(normalized[0].to_dict()["markers"], ["3/16/2022"]) + self.assertFalse(normalized[0].legacy) + + def test_normalize_events_rejects_markers_outside_range(self): + with self.assertRaisesRegex(ValueError, "within the event date range"): + normalize_events( + [ + { + "start_date": "3/14/2022", + "end_date": "3/17/2022", + "markers": ["3/18/2022"], + } + ] + ) diff --git a/tests/test_render.py b/tests/test_render.py index d39bf92..477abcd 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -35,6 +35,44 @@ def test_draw_calendar_matches_expected_image(self): diff.getbbox(), "rendered image does not match fixture" ) + def test_draw_calendar_accepts_dict_events(self): + draw_calendar( + month=3, + year=2025, + events=[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "filled", + "markers": ["3/1/2025", "3/5/2025"], + } + ], + ) + + def test_dict_events_do_not_draw_implicit_checkout_day(self): + result = draw_calendar( + month=3, + year=2025, + events=[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "filled", + } + ], + ) + + self.assertNotIn("3/6/2025", result["checkouts"]) + + def test_legacy_events_keep_implicit_checkout_day(self): + result = draw_calendar( + month=3, + year=2025, + events=[["3/1/2025", "3/2/2025", "3/3/2025", "3/4/2025", "3/5/2025"]], + ) + + self.assertIn("3/6/2025", result["checkouts"]) + def test_draw_calendar_rejects_gapped_events(self): with self.assertRaisesRegex(ValueError, "consecutive with no gaps"): draw_calendar( From 88849b5d9b960f2a627947badee57ca19e68a045 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 31 May 2026 06:27:09 -0700 Subject: [PATCH 02/10] Adds test fixtures for new format --- tests/fixtures/march-2025-events-v2.json | 18 ++++++++++++++++ tests/fixtures/march-2025-expected-v2.png | Bin 0 -> 9368 bytes tests/test_render.py | 24 +++++++++++++++++----- 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/march-2025-events-v2.json create mode 100644 tests/fixtures/march-2025-expected-v2.png diff --git a/tests/fixtures/march-2025-events-v2.json b/tests/fixtures/march-2025-events-v2.json new file mode 100644 index 0000000..18c9b69 --- /dev/null +++ b/tests/fixtures/march-2025-events-v2.json @@ -0,0 +1,18 @@ +[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "markers": ["3/5/2025"], + "style": "rounded" + }, + { + "start_date": "3/12/2025", + "end_date": "3/17/2025", + "style": "diagonal" + }, + { + "start_date": "3/24/2025", + "end_date": "3/26/2025", + "style": "filled" + } +] diff --git a/tests/fixtures/march-2025-expected-v2.png b/tests/fixtures/march-2025-expected-v2.png new file mode 100644 index 0000000000000000000000000000000000000000..29738bea903ac8f2bf05020b4b178713eb7c458c GIT binary patch literal 9368 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 Date: Sun, 31 May 2026 06:46:13 -0700 Subject: [PATCH 03/10] Better support for event colors --- drawcal.png | Bin 9368 -> 9688 bytes events.json | 1 + lib/drawcal/drawlib.py | 33 ++++++++++++++++++---- tests/fixtures/march-2025-events-v2.json | 1 + tests/fixtures/march-2025-expected-v2.png | Bin 9368 -> 9676 bytes tests/test_render.py | 27 +++++++++++++++++- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/drawcal.png b/drawcal.png index 29738bea903ac8f2bf05020b4b178713eb7c458c..38a7841182cf2100b61314ca2b67a46ede4cba88 100644 GIT binary patch literal 9688 zcmb7qWmuG9v@I$E(j_2`AR&!(4UMRTfOO}OBOMX~(k{vsYdr*n zS7|a5qRP(6dnqnz$`_6Ix^Mc?=g`?GHK%z*MG!j{;^S*G*+&XxlB&4PU|*udX?W%!+knh`Fp~KCBQEif}(C#KuuD()Bkc@bJWzQsC|Ge}R45 zlGkENhwwiq5*1TBR5T4oC*cc_6J_1FmtQBD^wzs#Ru5MDy-NQzadeut#*bDK0 ze)w4|y0W@DvU7I3#_G9c2M>DAIJ|Ra`KMuq>Y6s{S)8b0TgQs*DX}M>fG$ptQ zLS|Oh)#;dgS(1*DQrlWzoWFi>w##7)azSC?N*`-84Frp=MR>(|A74e;w7 z%ek6RdB|OKfB%QX#Kg;ya1Z-7E>u)h6g0G~($dnY#VWHgKQ~VplC_x+liH83{r&wq z%|8(4YOD<5w^wadpWN-PPt7>qym8s9oucbVOJR0pmt+HZ4SoD+FHkWfznL9gGJd^n%nWS zU_#b?uD!$MHYVMsWSiB_<&6z#MMYd#;~4?M-rnBknd@Yk?$C6(q0HO2=>-LWett-) zuG&W|EJ0;k%U7^t^hLw)t? z)rWYt7XFgb(y$mtRogByr-+0EnU5cFAeSvzYRw)(d2%TgEI%!#Seow6HMwDbLgeyR zTaYRK{_Tm{ovjXTXb@Zoc>otSUXZXueM&)OCxr+ZZf2=s+d+ygkyZ6K^qxi!{b@%j8adE|@r&B(hZMn@K2K^#M{j))o zfK{h|Bulykc`Yx^qu2W>ngTukr17b%kg>6`;MurR?XO>1V_VL<w zQ~s{4u^ac|$1-bzENG#Gt4d>mC42k$fW7?TSb4pzBdW@?9VIWk>`SP#1fpit8S(a^f(zzrN0`hmj&Dt*uRV&~Sq3;o)(4d3ku;$BIWl(0Oyv%vvX#6{Gowa(E)h za;Cy}bX0LLMWCx#z0!3628HUK@6I%w&sxyqqwY6e=x|%lJ8P4HTiad%?=SpfU}7>FN);;BZBG4Eq@vGB>;=bKpPp9xK#P|rpB8H+ zz^Yx}4mKk}*qz@Kev(!;HwbFFN`qNCoV;4DC_g_vJG*^$MP`E6eiPfZF}1Z-Y=<+d zr@beNE-*Ma$_gTd+e&FqrUrJp;b#9=c215qk8atNVYwQw^C2auD<9eht z`Em!LE)6Sdcu>$o8r$T`rSyWNM=pNYa$Gz-^-2>Akc7&gK9!pc5GN)jU0n@%3U6AG zW1#rBNo!~jzm5rVIa$}{fdPmq*8UZ1n&uvxpHH9qWuI@dRQo*u0d;ltyGgf;1*d0d zXoiEyd=iq9M}NK?M5d<7Nl3J(|Ae*qVc-)G93OR2=sLyR@HAhAI@IajUr#aVG{l43 z4rfrxO`kBoI&8y&<@;%rn4GK+T;ANgF&!kO{SYtW;=(83xchr*%4v8`kXRu=i>U=F z_1EATBBlh91Z9NC|KzaLNv^Res>?w6xMkp-E><%Jl1*i%o-(zy3O)*H09$|vWlMS& zrJ}O3^5KkW+D8qIpr|Ok)y`1Ep6{=wo+3?)iHkoeYlib@WMl|lZ)P>FyC3(^8JU<= zSTFEf9D0?w0f0{y`cgCGaz5#K(0K9o?OVO;(=8FzW6sbR=RDk_15)4&qRxP^q@>uiiVKO{ki|U>g899|{$K z8}U~(LY5y^bKI$J$33E3AtXFu0K+VfyJsp*0|Npc3Zni4Ae&!K2e}y;UI76p0Cy%C zgL_q2v}*OHge9b;Y;P}Zb~Zq*nDocDY@~V0NlPOWhx&d0j+>d8S$yKOJFPD(uGQqm z2RPC30NMWThvhS0b_H<>ZtD zMFSRQY$mze($f=oc=-9<`}aqur|km+81nM+fOqI$zkbQjp9mnNwXLnEzn_MKW7Hh( zi$+)hKB`izHewn(sW)O?-JdE%@-;h~W^`<9ukfOi1a^8oFVtjXXICk}@piiw6$4|9 zu=(;S0y8u7raixghQ{vbPGB+82$XZ)Wh3R`QB_?XnUXSaf46uKBnDlQpJD+G3(G?a z2?_}X1_fJS07aS(rB;VQ z0AnyPFcfQ4_wMhJ%elgxXIe$NKH1o?17RZwzgz|KN-j-!05H>KV|gq@*M#4^Jg*Q8=j@Y+!zV{!*(i+T@eMq;KC4 zg+b478Ztp{sGkX+4@O_xdSFG@p5BuiBA$jCx9N}5U`!Y2Jh zDy3RIfN7-M-G!EymjzFU#T^eQ?5pL@_k9!#+7}loqI$+HhKcd;+Q5wzuQIT*vXVP5 zJ#yTimjO|Q!{N7w{*Y+nTlVino3)7?#;Cr&z75B{jF;Ef|I}e>sG z>wZC>7Eiv@br_{00(MFeX`2OYr z1ph&mY!yAE%Gs^y4|m~43dIGB-~25~`|y&SRPWzQF_S119~3DHjzkG9WSkJWdRYV0 zik0*%=?CDAOBZ2_Mt9*ZTaR1xc`~<$OBOK~7b1s&jQ%n>n3b1i%&`hOoixB$uO==t zgouf(yM){ALzIn0h#6Ld)9u67{rTkw+cD~TwKmy8@A3W^_rYGH6Kq(>lEybtzesDt z=|SV#X4b&6`Bqc%nIS zw-ravKWjX(nM-X(i_z>svG}Q+yM)yk^Wx|1=ex^oNoJ*DyfaUmgX;dP`V&Wop8!6) z`vv91#i_~3`4yGume`H>;V$-dI%!>WW@abjX*2= zPP9a&Po^1+Pk=pa{%{1+oNq!8jkNs}aI(IrKGK*RA zY!-Ccm^|?>WMMtIBeHpwjI#1@a{Mqe`%fJN#PB6Qn+n4wTp$rzeEb8$!>MU#;#XGC zjEs!fVSltM%zy;N!^5+>bGvoMBnXyNu>-mrlR{YPT`%I}$G3crOi|w6V>GWq<7Bhw zFn0{hDwR`ENu3e`XD3|x@}fu<>_tI(bVx)-<^wA-@D|>eT=rYFo*WS*vuGGiK%vmz9UZ<;kTH*jJgton z{@elm(_J_c)zE;~-W4vM^Zlo!y@LZl7bOi1zY%D5RTVl6)>up|^a9r>-rWa!ebonr zIQLMU&F$BQ*Wb_hbj(pWi24;*3rr9vraI$=-)lTEi7KxC_3^ppru|1f=k|oz5{(b5 z$%oE=5IEqmM4?hFI!xfElfUfCh?h;-okDZn9t-7{lw{S_0V-9Pv0#jOhl{rgh?_{W z#!U*KlVMk9cd6loo!Z&~8}c{B``Iq&;yo7U%ne!Qas>>{ketZ>M*3dnj&UoUw6@4L zi>T?IX;4~N+5w(z$yL_D`zJ7Qfj?w5Wz$}D_&6U;&X^!t^{<6w^gB8?_bzNw(r+3i zwCwDuv$Gl@QdyS73(?V9G&D3sbaYou{?>&>MLk219-ug9Sd*G# z+r33UW3>{y*hj0~y&I2IMBqJG^zNWT2%Z$LNwlhr9X`G@&7XJiO~m5ZvcE{T-FVLA z3Yp<7mDGRS|5d8#C{bnSPak>;=JxhuAfT~{h&Cf;CnmT6zhq49fabovqhoY<_^oD= zp^ncIn~$2k&UkaxGZ0~)_31dw5olW7x5Rs)00yQoIaOw6EVw{nrdxFAF~+Q$2RB|b zr|0eH?U4)bbIJGA9%gX*o!7==pW&SWa4!>ONfZ2ml-heIl1}{R1h6Y~Tp}V7J3F+E zA>H3b95yTM3_w2^Lxg5W^t0>gBqztZgn$K-o0;idSZEG90w zyV!1?DqVC=>WsNJ69#5}N^QwF$=Hi;H7M{jev?wVpMQe2dNmquT)@neP_T8E+GC!U z)w$*UG~5(}o>+igPI2T^Y%x!Y`Ye)L8yScjAd7-`cS&t+ZNX(tdZWihr(U4=WIV0b z*!%Hha%w6La54u+1r!F8F1s*T?|*?g@)()8{SH>P0{^K>_6c4%2=NHNfp{EzCPT<$ z%(*PJ@Bcb{_~VVAe+rr6FA^9`$Z1iiD}z`^M`s@VhB*-s6h!&@b!!IkLV%{MUqt`7 z{Y$WzGML2<)ekcL2&zt%C=@$%tX6=fda^J3+1*fl(D4>%BRSq(xao&2iwm-Gg&5`L ziksw}^|b7)qZy(LfdB1r8iBN;@UUhEb7=)=72|k8Mg4H~gK+%2*Pn-&?+&b!*`s|= zPUbMM(~!>UTpyrNQd9e@5W2aUk5i;yCfZJ9gm|)D)J(E3OLT_f6Qb~q&kspI0w;j2 zYnTe1qZNCd2(W+@b}OlPI9oT0XuP2_HqwhnV4)PPlstT`q;0|CFfInxW(l1?&t_+5 zQ7|zLR@#kbGWCF717U9O&>rY1DJ-OAWF+xv5&8TXyPyE?zs}*nqfccJUofV^5m(h^pnr*92_t-Yb-0@=h0zWh=N|4 z&y~|BJ6j@_NnOI!lx}9G&T)z>X#hBhHtYR$JGm6XZqe=S&fw4AMxcMk3&t=dq*Z6@3%W#9wBWbzHQAsTbaZsNvPrF%M~$m$T_HzQe0+hd zP19|ip-CfKRa4A{Wl}t#X`h3o>8q|3NBij`(1oRAb^uOdh$E*q#M-3Vj~8R8^G*+7GxDh=_>j8ms4XrYrmV9t+H*p!hB4YZDR^HGu2H zz{1kf)isxUw<({MG(H}vRc}9&JIj*^cvQx!WVOPDY_el}cXxLpMUfsK$e-CcfzLMO zLXANA-~i!WQEBHY3o|#D${SOM?AU9dBR!#w>dAGRhTC5!S-*D&{tu`xuOg&6$NuH* z?I5v?^uj{dVPffUli}If-jEk#*Vc{^S#Gt+qiqb9LPCMH&ai3+clxO+S*fdzH6}@#RsR<_89T}0Kx_h1svFO1#C%B zrHb?O^R>1eHIxl-&gA6ecrFXtn+uBV3EyZX@v@6nfLpiD+$7wV50bpjZcVn>DPB}i zQ1Gox!tar+gx&cozSPuI`j~fJjhhl{;}#KSPgXGecGHs&U@ zcf&I?4TP_u)Lr4GQL#08B5jy{(b3NUD`eNysMI7**{;2NMd4BF#i;t4oj4T8h~=#< zFO{Mg*|rSRa=_Fj?q|8VCdnyWm~R49igFrmzn|GDDdBi1! zKsjB3ewUxbi5W;FclRft(oD{G6M#yvwL2lbw)~cs#+s0z9vu_2y0Ot0h?fVPZO8c3 z<#R{=gg9Q<*IpzAppmXeAJ%P$=X3En7L*}p|)O&N7{3E*DG$H%)(Fu$Ij zdTQL4jG3v|?CJ>L4mXW8Oo1k65ZGbUl^Yg`Nt*E-uVVV*7>&5n+t7gpE@N*`v*=by z5!@lBrbfTo*}?Y?Gv-|f$R$1x!K9E7CLpI|-Uc|ur)n9QkkbhY_FvER^`Qaz{(&D86Da@i> z-~XraxXHk9ID9@fPM7$({K^74v8~oCiXQnJ4ByV#>LD-!shu#2$Nm`+f)14LBmJ;Q zpsTT|``If1T#9|;lOk`W6#vH7)h$6}gaSad4#kI;2>e&*Lnq?(53BtIlcu1Y;nPKh zLRavM1B!FTEwQ#EVlpx^#}d|hH85HEnJqe5a_3Qhi#6-|6WgMA7C<$Bd80`DrC| z3cc}gcWX%CG<&I0a^rMEr5jcr9X9UK`|)0?YEN{MAiEj;6M)0@p|rlOG1JS!Jnl7gNl9Og-E zYfS89d>d=Kky5E%Z_loylS(QOYc>(f*roPP+8nZ$4~eKD?KY2qb11`pF-Q3MNdcoX z7@&50V3gZ+ffZHT6XTXr&5QRTQq%wmxse0T;+p4XXM;e82M`mKB*-8xu9~&h^E@im zTpN+E5H!L;@gfK597P-wf!O-`QmdFZ|Hs8l$wt&YI9ToYer=F_Ump2yAIE+fJhZ>kpNcQo zxIDffzgEoV=qZQ@aEQT}grCN*s-y%1q`A7C$*+RaQd-D84VbX#&s5|9Yvl9c5|ujF z-$71mGc*0gCLfwtuT~BYka=mlYFWwnA`1`RTKSutIF11c34X#Ej>#K@m9XW9#ewY6$;sZ8l5C9;#c@9H>0fj18PcQ(M~h)6o_%>nZdk?8hSc9*um zl0j1hje);^SZ!@NKpfE5*=&s(s~d2BNzAIO#H*aKbb{1F+}s)pfSF%dNDR#TnM&W< zy1HKTuMkfdJ9~ z0v*|DjOVlSD}Kn;G@&n;J}tj_7EcEYwQDR3iupR9Y)uD0T)+n-eBOGdVlUS@bNElu zU+OCcz6_hy`S0@hL7Hz7#XADtfNIsFfAR65c+t72J1JX9y_fZXmkvr?YyR}#+jKnt zin(n-)&i7z%fQ$FRCtXpi?+WwfUulzLd3MBI3n)Y>YzS@9f4M{!W^$J=o9O_F{{yBS>f z1JYi{YScqY1URcdqt;GU*JJwXzOSfL3yu6ea$N4(PwLLOHJ$vsN=iBv*$<_=o;O4~Kt9bIK*8XTNuA8!9>KP|paMPgf! zNmNOq*|v&juB>3H_$@Au4tV3~>2vvIFro%28%(tO4LawN)PVZGoaO$b1>&+BwaS+) z^emp|ScemJUNbc`VzQGwm$#O*a&fHb&^JrxM-=RxG@3g}VqKZ_3VKR=BqMCJL!AY; zf~9FNk&)Suxmc$o4}<-5E^E%8HTCs=$H(t!=;(S1sZ;?P|63NXCHknYTw#kIu=k_C zB6!|#d9NHotwcWBKjs%GsIy|4U@wr6mi8`R*uO^TyzJ5T+uY3_TfhtU=K-O4I{^tU z_+rSoz64e0`hfF{ey3F7epITkCR*X5lc4`m%~QxlcJl!>>jC1?(W+5k&sM?6o33dn z=WLAOe43wfSh*H&JLTP4c5G~Hx#`dp@Q{~RSFO_gx`-+^?$Mq_{h3n@6!il^@H;$ZpdAIzqLC*7IuAZ+&Pt;Sod8dh4H$dRUvx%yfip9{)t$D z=bmBJ!98-~ZmAx}S%KkYX^5!%HW8{g-Yk|*CnK0Q2yR~@qNKhr=5OD!y?CJxEDKKJ zfhA5iZ(jR*G#XkuywfSdj~>6!zE(_can@bh&UO&O&+Z@nI4gR>beoH_7es{5rE0C# zMv5zbhvq2``~hf~`^OPhZA*W0I25d{r2Qk;*9pM% zANkXT6c<=y>}Uj_eANHIfu$u%Fc+C=zNZA=4AEU=?@xBQL;H+09$$g)GwA;$FGz3s zjkf3Sl@2r;%_Xdo0mlRB|Jyt`X&GYk4ap!Ap=Goz$R>Y{IJg$M-myFD}iEOzrBDx zi-`N*9|A0OTJk&pSpE@RH2>FfWba~0aj_U^#&~&ALZ!qF3?9FFMQA+rsL3KnqeNZiv6m1plWUbJ z+*(QL4gbP~kw}VMaEl(Wa(W7%L_|k-pKd9AEHd5`Lbd*A1POZgz7MF7mL3`7fkm!_ z4~?BD3x-&gGu|Z$J?>iwj%$L?5T{;#K^s+tm%dy3R@b$I!bE_E@H%EM;fL+oEh?ho zgXsfGqol+Nlm-~Z3=F6$VFA(b*})-ZO;$w60r7ENz^LKhyy3q+c#G=NH z5P|;_Pfx5M5N=BiWd(iz?9H42UDJt~zis$JB-|+<)uEr9MB_z?SVSbR)?E`}bf7uC zmtCik$}^zNdoQa-gXi8%@qJnMg*WK_8nK~K*?@=jjx&wuMH_g;OX(SyL29BCa4v6g=dKI7qJczzEv$m?B}4Fcg%4_`jWm!j8i< zG&EBBmX7xGE@jtF)Yt+Dyc$_!u3q1ZZJl(9HWTjNUFp}UvC9eY((!qcrGLG*{QS>5bmtR|K8mLUgp0s;al+1Uv(?4gXziCuLAGTY54 zF14h&{h_;R*47+<0_Adp_j(;C-d0p7+uCvm1_o|iR2o;?btU@=dpu&XT_3OH6BMjD zjV25Sms$0t3su)VO7-4fzDG+>zpz>VQx(h{A0K~bImc3D-(yD$@gf`PVJB-P zJ-xjcQ{Usg#~a@e$3Y6(b|Y57pIrMn(>g>OrU`CnrhB$(2k@UUdqaD=OlHM}smj*qbgdEfL5T2Z#Vge7GrX)BxIARkLJ{4(Y6dNpdC9;53@!Jz=^k^)n#D{>J`OZ`v9{P$) zNZ3q%cwF~bPEOAM&xjf(^gIwm;&oje{m;vbzZkG?(+!@Tbu+!Gyq;@cZ?H-~A$d?r zn|${|vdXe8DV6vB>*i+GtlREgN!MkDCnoHJhQM`x&AVMJZ>y@hxRPZfR`SATtNK2D zZo52pcMxZu4!b;ugxA$cKnjZ)lNAf(wY2(bob(M1UxSBSTU!GU*||KB<$1ifVWQ z@5}BI&`x7b-lk??h{?}~oh>l5$v=E}?d<&Ab^Cbje%LdEa>MqC z2?mko9sGVfF_2)z)7v1Eam(Pp*UO6uKK6oqoSVz9RAEe3=Z+Y0$o>#*@lMUul%=Di z!(-tS>C21^@ftxpTj>?5uw*pab^##C^n%;K)utg{OBk|6TPpBMDF z(2ZA_lS3Gpj(xkll$9elFVoV}=Jt8i?EgY z2Cn9o7G6OX_a>RO)zy!Ft^3zO=^}r&hCoRO>U^xNts$z|IKNh2aq%Z6x3(g?7dd!% z>~t)~vPgSiM|D{T7pL3E(c*j8bTS{7=oAze6P4W0>%wKZ_li!YHV-7}{_@e0f98PQ zbaO!JWN2M&Ee9_zF%uKh(P-XK+Hb4W0K#Ign&LeHgXXlOF8cLzsMM2uX!DJ#p$9(u$9N`|*0qi^ZGNo9k_Jbr>I*RyBO zT#!&yyQt$mkP*ae{t%J2GsN%pb-1{AWOK7DA1&Fpi3ypO`{VZ1FQSJIbc81$F$C+{pP#ohz!e6rg#RCF6VbG(T^+w z2>JQ>`2+;KjP^UJ?&5$;!fiVCb36lbd$y&guTSa8lbX@f`moE;Z{NPnzwrG$qW(pEU!j=V6?}d63H5orQ8Xg{aMli2{ zqmiehNT@J~R1%xqv*l$w|DYf)IGnn%vGE%I6*GmB(fJ&|Sp|Tyxj|b}Sf9Mm6Miys za*XqYu<6dz@?d~LZ_CPFJ;}iy{N0)%2P>A4KmjTARNQVo6=w6F!*lsjL;7)*;&c6M zAo;tt3+dqCV6*WB>7=b zfX^>ZCfXKSPq#WB-j|C4@XjjZOA!zd;Qza{B-6n^Ai&zn3L>3z6%P*&qFU`xYa*0- z@^Q6StP2!OVmDh51so37b`r5@W*it8kU)VIfr*MjYinyIP~x|4&4b7C>|=%e^_2Y5 z;}!GBGK9{nF*{%eE%~m&$_}Jqcx&sO3bRHU#R4e!2g35b+MWuesoT%i#LgJ1#g)64 zQjq+D0z9=O)^>7{=DC+THa469mRx$d3=9lnlaf@C$U@gFKs?#fK4f5`BO@aKd$8v8 zY31VL;tf8y3GxXG*R@oYmbU+>b>3adu(a{_X9U9~CMFs+`(s?s0b#p3#>)6^gc}L^ zo=S#~h@^|O9^n@i6{TnveQ)&U)fb>k{ei?i(02@SZr6WxbMrGu+zxa;g6Uz-BhKd} zD}y;o3JQ2Q9%5vS-JGh&idP08G7}ZJX-GLaId|q`*cZN}-2_FEqRtH-GTEFp3Cn%s z#w1~RT1EyJKY#CGQ+YW#h&&+4c2EdJXzgeeF%@Fek2p;;3WSM~f+!&&A-}5XYHx2Z z2_>b9lF}8(cZ#nQ6EOfq8F8$GyDTg$j8)1ja(`v%rUVEy#Z;YZ=hq@Fw4<`B>T57z5b@U5RtHB%%;_@LbCC$3kPBWvDDv+uLAOs% zPBLpax8^oC6W_k&6Tf|%3kHLL(@9831yKrXUaT3Xg~p&ACuV`E90Pkxs} z!k<3HQ!jczK|#?_bepZ1h?*?w`+Msv5-#7v3ko=1zI-_cfQXouj8KeHL_~y}n>+vY zYkZK{xFj&weydYby-rR}fDZ_CfD{EOoT&f>3oZf9svBS>+4F7 zAKwT)D7Tz!X{)biP*6~C+wMN9V$K_i8FTU}pS4A%6c_WTbJNuS#-Cmpd#w`hadzYh z$g$G~BYHfQ5QA0#P@IyN=Z@|8wYZ1^b&XRREiKowR4yywa?dNObg=y7BoHW_h2xdH7@)WYvTl3+{>D#_y&68x$PK{B+}vE! zdzm6ICe`xutE&)@Ri1|%#u>E>3k&%p4#UI4Rx$-$fUz4u z29%YTFJ|^hK#AJ8W^uM_1u|Lz`?ATn0H4tqBve2Chy`RNPyoSd2s}mVP~~4 zd0};=5R|ZrzCLZP{{m@!G8Ln1&a@m6$O^tRO~A!i1!s_-;7ME;`9U3ShyKM%dM*$& zVX+1J0=p>ov+&*{t_``JVeLeStbJ#ouU)=D+t2G5>n2@t3cTz zaUI5RFw{mtY;j+~#B^$!hC8+wVAr0yIWr1M9&vI)RD5u_aeN*8=1lqYz&=HwqTpSi z`yeMvUxt9c@gr)bAP5qS{7&RQN65}g+z~#s`v!MZ@K7Y;SQc7ZqBFwSNEyDfkUHoR z#(i|1KA!OLFuZ2)acXF(k}cS!%uenH*><@ zP1dewuvOj%1y3VR=&Tazq=g`O6sOU<&hgdJ8-6<3uER&J&1lqgwS!=E(W zIXcyPp4*C#Fb>zMG3>X&{=ogQ6Q$-nxz#UlEC+rti^?d8g1WjlozkQ=UTemD3`FS_|UY3N-+E?3` z>FMxMM%2*CV(aO6L^skRuqL#HuIcA#N6l9;_P8;p&r#$V&tJX_-|?XPz0RVms~b6x zwQXMvT8H?C2BWF4kNL$Ccl|5k$G(1j z*4#WaJW&!e>7r_EOc;S;fRC+iY;-)m5;HtDMhPnar;* zm?;519w!$UxNoOEQdUfYNKb%nb?b2YsXBLPY@&HWdb;6qE!{J#$Vfgti_pXXSIFE! z@<*-P_-mKfm4|uc@N?P2{Zi9PqjgG9$;niMd2O*ncTVx`2eUgreuO+s+a&4Y#ud+;`jC}TG@LVQ%tnxxkLc$nmiT|jde~vHQ-RY$208Q2A z=5=l7pr9b+OjD5O*0f4O!h1Q#?zst5_(CUYo{74tE#+hOD&?gm3#4SLdb!IMQBYdS zG%})hH0bw%Bcsprpv?YG^41pHpTFzWbad^_br~59FH%!ImNf5NWxw+bz`md{O}ANh z@e1b4Pgt5J#?_R5Z=YOFBI8Y69UFUCFy!;+8$E-wWNe*J6-|C101Fggv026gGH{^- z)d_xnZLJpgLU-p!?>jqgn2z$C9FCUeW@^@E5|CUVvS6&?nHkE|-cQ=dgsv{0$e8lE zO*x;85o2i?89hDE>sb=6pAI*v0Fg5@2%FhfiAAeXa;6f(ZaR}!nf0olbPC5!5JQdLvq5OQV84LNj_J^Wq>KJ!1EEJ0tD-Hx;;vciMe+CPc=G&D%T#YK@- zA~WnD4usEv`sU!EFkc#3&yv^9aWFUcC#910&f(z{wLh}HbXwdr9ALP?Y$@Wz;u$Vx z5I!6xUYu{g8v(6IP0i!g-@ifMYFqp~<1c#i@O3LEI7D6d#^AB7h3?@~+-Yzua_Z7jqt8UuS`(_P%$Wq9-B1 z2R1Vf@s&jW<_X3+=aN+Z-oU~-m z(T!Jc+$iD`5>nFDg&9_A`ZW#eGg4du(u7UM8Zc*pVY!~JEw*7F3&qdXoE%@hAEarKZ8l-jaavi%+B{-XrN4j7z#vZgA)y+dqbWGO_+U)6pj1`Igvc`h%es*!RHFsitV0X4>GixsIvTFcXgJO z33gPmC8QVrSlKWunNXedOvFnl}P$~S&>@Nqj1RbUamVJJ}1l~(JJjPGakwF(Q+?ddVPQY$@j*i>Cj9z(_M zsbw$A=B$8|O9xJP4<5XDo`t34;^N}t_q;1MVS~x9s9>#kGq`UQdc0@A&CAPi+m8ic z2{4(~sB?PLu@A$7VL7U!Nubogw*^2P9vmEio?pfH(6HndFeWf|0|J8PuUzVbgNt_o zo`Ra^aapYDViZIqHj4l9V$#VyqrokDOVC{#r>h@PL?aH;IFvT}F87llt#o91dcl`% z)PWox+oOK^tu^VY5I4=;xBAwg5Y=@I3o)3u#-zffs&DL!qA*#r#|%#xz{Q@Pq<4;g z!9cGwJUK}U@H-_1T5@X(yKMoaGq5Q|pgmL+avW*oJ@BBEF_+F0Sk3l`_)7pv*k%BP0q=f&8Eq-@E=%MuJtJ{iMHIr~L&wUF@;40Fd~=sB`*_$d>V&bxZ^k7(4*+zV7(G zU)=W%z-cRqEWptb=ciPPY^1t}1c13}&n@|A6^kBNPkt!kyLnU1 z+`L7tcK!En9Ed0uq!_z6KY2n6=97|M*I&0WX`k11aZX&~GHLd?FP@k!x%&?(l1)tA z{oPdt5erqjF4}11?=rxNy!BB=(W=}^;E>Ra3coimTNmKQ=v?*8Ns2*E9VDz?6SmvR| zoK$RW323felf6L<+}v7cFyKgwtW z$dVCz%U{22=aa+E!ZoUPgTII`+iXaF_)sU$O;b?0DFIlaTfcLSDXOy4bzh>yr^@mD zQCe`pGGnz(mpv_wOTMZ4GO=xnaduZMM? z0TCMmK6U?Rr{M8E4LP%4bwEz**qaYuYXh!$Q)`-99`zqRbYsxeS^wMmH?#W|z2e2T z;#ZF-tQUo%5HZ|*OB$6TMPlDjPNBFA%U-I2lb})H+YXrR)eZytW`!QsNEtx~+&O-` zJ=uSH5&BoIEH!Mm`jAk(EG#KuSQ)aEf0VKQT-48ro3|VX1Xx$FlknbQgfnA5fOqI} z>~%u}Em0>7DA4ydDz8j6gi;;fe-UTP+hw7pjitMpuEe=}x-9v9iP3w_<2rBr$>@kL zaj{TrA%%gvySvNe2h!W_vmM>+?DF^S2_eKL;A0KGn@vJ_1_iqEg7iv)^w>WIGGBhy z{!9I7XvFml%n$()_C<4IFANj|7;50iwJVwu?Z4B`3RbsgNTkNG`U`yd>%6oM{XxlB zJG_S|$uGJsE4jL>PeMSCLdKIy3jB)hl{_pxLe_wOqsqK3vey={^%E)ie!YvWxj7h+ z)g7G%xH3sd00D4re4iw4^q4s&lJ9w1EfY-~`%&@ZlY!4l)!yOs= z^rD@ttYAdM#Nb>)(8{lFd|TLC zn}St-c4>48A`#ovL=Byz3J*!ba9s~KPsbFVjGC_qXq!USo|*$&2@W5@JzYOp?}yl>0QLO}#n#$;1b zg?GaBHOR(?gzIB@gVKJP&&!Pr%L$q9$p9se!vKAKf+)`KT<|waN=l$h*7mmR29jD? z<+BfP1gU|biOtM(Fl+F5UP`pCy$F1fY`GvjU^3|n&_QHVk+SHkS`EL09!wQZHX}jo zNvNpU>mxUNWiGt7c6Q#I|9yHRC&wI|oYJR;H6SJ3Z3x8?o|1MOzwu+{^@BjLkHAz~ z%1=Uhet69J&t%_6l(&}D+P@)mbaQ*!)<#Q8ss)3Rq<~Jw)wLe|$sy@>cTW$AAbtGQ zxq})|`-+7l@<1zrp1{C>5@;pufsv{5&fUe5{0W>F{mZL(cV|a*Fb5k8n$2rEtWlhd zz>*dcssv>0d$T)JD~8bQK(3OqUzglQoY`~lSB4Q*;)?Tlg{oQ%z5S5%WpljDWP@kR zjC1^Fe75A3LvPaS)U2!&z(WNu0SM~mz7zIGVUBq92(bx;Pr@i z`wLw6n=jjTbp5w%66f_YnK@4j7Pj4g5xsKDE?9NmEZmKQ!|qvtJrqFi*KdOGR_yjj=Fn?EZ9w#@P0_8|YqM&+o~nweB7kST8~nn_c8 ze;LlYfFzGVq1qRBfy(^1YQge1*CT@770Cal{ zBNMloy)vF-h@5<0yG{X-&3b1eSd%-0sGJq4mZ#?9L#e6N$Lo>@RE!NKGmhw^Fjxna z0jPsaCfi`0vgBlviFZHcK?}Im7A6PufhX8XtT3&oZ*8?PYYnC|Fc{N`p*}h};RGJ> zO~W_yzj{SRMn|u-SR`+3R380G4TCV=JltipBb zUtMa+Kzk899hse8N5R3N`D=yY^@ly42M^K_ql|rUiB*hIit)#oZPx~3rrV<^TU8U1q^FN#n!}N;~EEQ zaIPuit7<@soFdy|z#6ayYQWI&>D-)3-2uWF(uAGq3tReXm4=j^nf?J{TvJ!aaqCu8 zDK5yul!j7WX8wYQ-FiA@?N;(gq!-*6U zO5Zan^3is5cN4Qn*eaIl=L78t$RSc^rFjykN#JbBg+qnSXUs2N(4as4N|>-hBRAAL zA4+vC?)JcD^mLp+2dk5JK@}Yx4W*@BO(*(q0%yEdQ%mdf@jkSw-^|CcF3MQDL94RA zfi|5jSd_uZhZB_doYzA>C<&CB8n0HGl#Y(h!No-pq%Y8};;*Jww|vjrWs$(tb(u8kDWdA_`=xI4*&6W=HeG!!7QeA-a z2I09B#Y7|P2a;hIoM1l-jJ4%~1(P5mjxl@2Mn@I};II3`V=d3~6Cbb(NB+@acH?6> z-sYx>vWm*n?#20UrSEpC^UeuELkHzN)p^L}4ae!t8eb&;_#quL6%erdadCGeB7bIV zc^TAwT(yo49jB25RgTvdtgGTYL6~_Jl(x*a47jfL>wk09^M_2fzW>lY4R}$C_ z0g(arM7Hz+9dLnw32D}5L5kL1{xeMj_&72u@s4msem)eqOLabA=K^dftNQrdVrFI* zMIa(7Dh7_WFGJa{T)8qj4hW&=%Z~^kbMqS;C*90}=JMS-y9lGMlvDC|%G8zx`!SxaKjp9Z?jsWcdU+(yqN)vlA8aVgyu51}R z=j`Wk{%*^*oP@i#x4q(GRsS-uG9&)Cw>SRjHR)|7wzWkc6$+Eqfht}cKV7kv5Xou& zWcIY{|NW}RKQQ}ITr%lq*rWf_ERM_(iik{{N#$h!+}k4qxBv_YY>3&X8S-j++u#c~ z9UXemRF;*M;X#1;njvDT;^jr-Y8v6&V~OZmtaw z5U66M#6(ox)ArImRdwM_7s5|D+HFxeu#uy0m~=%+FFNpW2H#k4Hgj3oR%uV|Si@$m z61wxMY~`DX5_d{Af1jOAt9mLMa~8Z4cP`I+r{TPUDJA*OR|lDYR=yb;)DHf+L1F)Z zNRTx1)K?(0szQBZ#`B@sljDYe_+&TvZXtANXb9T%k9G-h|6jz`uVXM6eB_*&eJ|)e zE7eQnm#CE3|La4oCC0a;q+t2{wPBz8lj#0~rVHpNJ|ceCU5C)-P9u^0@pALw3?+LN zef?n^1pyIVT|z@c!(yG9NIyS6ksZwvbuRlwS#Y+swN>Euuzg}`Dym-G+gpe)eZSs^ zMn28qHj9RgyF>)R$jIn&JyxOA+TO-yy)T{t=ay||+z+seucCpS0Nb&bPHo1^d|fp6+8 zX^}=5H9x=S2o2*OaFte-6%1()Hj>8g7P-4?1NAynYinyeJUUwWkqG@zpgQ|GyR?*f z(fh`h%XU`G$cS7-M5OU-QYVeiCA7G>SP4eU%xu;bj*sKxCiJ(A%i{{d zL=*Q%-6XLwF>F8gE|1pcoreV}DJiq2cBG`FRtCA3kg_T(anj|xHwIIa*^H6F*(6!& zaB+%Zfs+?(Y-~s!^#?cc<9i4f4!pw~dvg+ohGYyHrRy1<``JH!bXE`QKU`~gpQJ7+YHRm3T`cy; zDhTmf^iXyRKU^tTSzFhwMe-xmez{^FF%R|KI{RE$R3xF6Q(K#Kd;4W$C|$Ecxk#uhwzY%b*b-vVWE2jBo!eE!{{q&uH!MN4K}P_r6^t zdHRAcx#i*RfWzzh)3WxkltcwDv)XV{v7KRyK-}y0sQdP> zM$2j|3W>?lYA3?!xwmJxlcr|U-a}_+CvChmSS{rlirw1Y9>!_KEAJ-k%tFwi+~#1Ag8D}^3zH;>_avw zJ^kOn#zJmlRR6<0)}4}?MWfEpH&f}XHx~!_#l_)IQ80gboS1RhOuu(?<7U0>CB(%I z>NHxJuXC>0+l2Qg_V)A`jDG)kyRG0;ZzmiZ8#`*jnI_;dHmRNqLZ@H(yK%K2kUj4899xOX_1kbuKV*L(6ao-#xDam7VOEjJEaYha&qtp z?!`Zy@avkP!NCX#60f+W+YRp5Z{I5G=sf0euirEDByRh~;CCCtnF>x$?88DgjG&4D zDrI<`YiaA~cz`$E-8iiB#4y@kQb9qYK()dN-nWxbqFoglLBM?sAmAkt(I7fYMHLQV9Ca=CFgCL$uDX7d-`O0&W5d3k|?p6AO4 z2f`a(8ECX(%n1hc6i($xh&1Fc$gw3!|2GHL`qa0`tg2|i?;Ud3CkE|}MQ$~fSbw|} zR)39}7R(y_p0qV-lT>+Pa`Je_JX1+cEug4~;ra9DCT3<(Ts#9T@tl|`sAt5lU#uDK zt05x&Lid-(6_u4Uzb#{#F8@FmJr4w>rKJOPH!SX$bgHA{;yS*64Ev{6EI)axQEg*pY#>>^P+J=U?5-JzMD1l?tOrLvR@G^=Q&Th81w^wjmE<0WJKxR}o zFcWnqZ^9&d%%U?gi0_XRTO8(WYmN@vvBqJr&dp4pArNVg%atJ0Q3prI?2;0UoKcWo zSR;uaty%F*3b4Fr6oku~ujwxq+(-agR!l@jM&=b3_L)V4xAMiytIlD?Z;&)TK7QV* zp9wShX>OEpZ8)QZ`~B7C==P_IDWa$oX|$OaMqH_umX?N#jUGQ?Rufk5l9Q7gkNX$^ zqJaE(7}5eq7oAqS5{Io)X4`>d3SSR z%n1ScVRCnU=3nu17e4kw29rosRrPgUU0wAQC>2I%V-idAr5|7}@U%B?-iS#^%q1Mm zRCXr>x&vrg2I+R__b-03$(cB0pp=|AiIV%-1Th ziTkgb0lewHneN8pxQc}A{{*BZ zLs=s`EoQgUD;yk@XGL$`z6JWh!gFDIx(9?E6#sGnF7$m7?06-Jq%ZFPm5hw+_wU~Z zT&df8d+^oHFi^rcuU~ikkV!M1#%Sd-R$fv;TS=1F|tw-j8UgL+_XPobaYe%3MD4-I(_-M3Ce|m zi;KIuwnj%!-`3q7w6(P*!j>(b<2a(J`9paH4tEj7L%I&s0?P&Pl^SAfYyU(;sZmM~B@Pecl0E!T#28d|){oM_G z+U0#0ujGdhh!GJHgaYo-KnIS2m?;qq#mb$kCDcxn3!D#tcrSiwyBc!wYAvz z_`SWcG$A1&KESU#7cb0cPFe_>w$Tmho4__RH?lM&CRtxEp} zsKuYEg*#nqPI(NyM(2z;ygnp;%h)lPC=2TtT7;h zq~dpcEK@OglIlj{eX{Rd8_61M&_@=;b`4q)S-N#j6jXoIJZz`br{G2^NajiZ`b02K ze*BCjG*5Z_M3j2`A)&2}9Qv?*CvfL~z#}J$C&s${OS|Ot>(@^AU+BW}&g3nV_8$$Ju&u4Xu7wGYCov^N&$|4;cZkm) zAM>Aw*o1DbV%Pe>q+E1rCQHkL;>)EnJ~h!(Qbb>*Hc~$+IpawQ`4W~F(u{DiL>liyvR7{O&%qxr{O>8+>zlX4ZXha!t!_V}~*Ad$;D ztx=R3$+3cPQm|WZSsHNhcX`+&O_9G~_dO+8Gdk%cOB{xs2~z0cnXY0ZCzFy_ehzvk zE_~O&`IiedRueLemD1n zrF%aHR?f^&h<$F(Wte2r7&IPs-&v%G4dN0(?C>MRD=MAYAUa8000NyivtZZLYSy&U zGW|WnERCgt9L`L{#KaZb0GOPdPuNoDo};0e45e|Z(#PAJp{#QHGbX41spsD~IYfU5 z$*v19_m1wh9v1Al;JVkJ;rwRw!Q)Fiea7_d(gY;M!+AW)-Q1g<57c z<%BW$@c$W@X4O*o#bK((_^?vO<5r$hl*iS4%a?ii6MN<_*19>CxzqPw9s7F{5bZi> zg;9hnQF&yOXi0tj=fZ3@c?k-1Ds5%uIAEuhpepWp3>2Q z6esQBDTalG1yDy#4fe^+O+`&DqO43_J;FCUOvLZj#pd%ZbkY4nC4WLepD!rtVaMav z%d3Zh_y$Q6txCFgBY%Ih^Whc&kc9P~cN|>sw4Q-g_^&4{GyC`X`|NX%x)*X;}Zs%QMr~0Z{$57dqG38L+-CCZERBS*n*gIx*1^j$dA} zGkuz-ens#B@z(tyKHp<)L62})_~3O!Q;@Uf?VsVR_Ufj_n`4#Q`W`n;1%Y3IKIsC3 zb;pnyE2zwctOn%5b6jvd9?J5}R-txHd%<5O{Yz7DSQx|o{Wwsv;y;vQ($XYVRB#p+ zj`c@={`^S^qOUO(6BKUzBRkvo`*)6F&GIzR48DBxrgt=_+hl6Hy8t-^?fJ4o%N<); z^9h?LCY(nF?H{n5(VOe#J*uT#hqDq(0mlcc(2LuQrw4XjbN&9Sf-UCtb}2THsqeoK z)Za5LeUZyKf|wwiW4Js4Is#OKlk{F-pg1DpDPXraX{w_mN4yB8PoF;Vd>pn!J~}#I ze|%oc?JyQkoJ$uSq{vRrblB(N+w>u6qrklT-_}o|ET?kQaPqsOrEk$koX_zUDpyDK zjjgO{$kr?&DLN+O4mX7a3wI^7ESgFnOV0F(KIPMGAH@%oiir6BuC8`GTBTxqi=MBL z`LXOG@$T3{Lxa1d#I^z|L>!!%$x>BSh4%sj@R_u9-{I-0!z{(iG}q*dm@_x0u*Jj+ z{fF(WbKVQPj`>BpurA?9I1bL+j!gZ}XbM=|6$*(*6EZ&EepJuL4Xa?szj?PFUpxe- z*JU|l-Q(p5O@P*K(z4xIZ9!S3aZRT#NUf?;fOLvZOjPjpe&X%j3_2pW0T=N$8#~Ys z@$P*+ySSjCqYEi6CgkJiAKm^v|9QtK-@+FM<9K6o*xh-@e2CtDtz_@U-TFo-e+OY= z;vJh$`-yykt#=2;35A_}!9rA7StS2~?!GDpV9EtI{)6lkgwfF$AgTG(a!8I;VjXk8 zWcbnQ#VyRu&3U5kN&pxwpQ3Cd#Cb$&R`pm^XIa8k@95{>ea0}`msnx;PVItc`Fjh_#1OG|BkOOXOrRROfKvHquxmuoe+KlUJkzY>ue@^UGkdjPiE{QUGVr z&=tmkK$4VXtu)W?d;3g9Q}e5K3AK>W(4-kF=Oe++*ichaCe=BcudNy&jVSUd@10L* z(5rQHcFx{&b#`vnI%KDT9^@^vuH^fFGJvFVKKrZI!1TqqJgK%{@~x@asCOZRZGq3SR|3DSA0K~A)59a2$ z`}^nlZn?RXYy121Q0RS@Yiz+A&|l11+uLK9RpN(r8fggaI`Fku&8aK_SLF5f8Y~S zjk}|xY_|mcFQ6B#Y8$#IPd-B+kVm2s&l$BVv3AtPdWF3_9M1r-BhWsOzmxVg+UL)N z{WV0)_Ps{sFW5~U=G(2RSel)Ai0Y0rz+EI{>ow@(fs3fDV(xf(7{g}$yQW5lIVv)8 zeZOI9WqVsnPwy^iN5=9|E}Qh05DI;<8NpVT$4Hk4`y%7!jM&saWfagDawgBw z1B^jQnbgkS*qPmhn`c5oLbbKEJO?5)(o-U_1HeGgxiK*`ILKBmKt)HVprecV{+&E1 zFs$8ij0tOpjSddd!#}pD8 zAF<%9u;e+TS9f<$vMsh_uUg&6b4?Ke-ORgpiMC~O)Hpx_$v;h)lAXOx%W3QE-o$(p^e+C;gW>IQ^XKT!L+zi| z^YZeLaTA*r>@_qr{QPg2m3;2b91Dx!3h`0l;RZu#Gq)$1`lGY6(%Q3(KqHxQp!^w1 z_o=yCn$u~&K65K~DKbvB7Mv$BKzwoZJ z_6;Vn0Ao>C(gAI8LEY{|2EsMwt=UNQnvPS6y7dKaFt>kK?UE90G|Caqw_aa>-vAdq zu#xf2zw%P!wvny{vv#jP>SXMVq%>6j@TYBrvABlVG!z4Db^ktAnP~)FQO-=G@qK}m zW4YG_#(c2N?5RCY6FMRdFrB~@#+cYh4q!D3aqaLnM1}0Zjoxy)U5*#!rC^Z3@A*&2 zFE_Bm15*1)+X(&2oyPIRl0*%#xC2DK@*s$>HRhC3)^#BJSk$3LSTd0cZz;gKHZ?q)$m?vDGwSZ;On3~(li8Le$|pRN$k{`!I45m4{aFv{ z$O?uA8tUPj1N;2qT}0p~$EdBHZgW(BQE!4q1qGpiymf8bZigS71LD8Eqyyeh&wy?i z%<4z&bo1@f3g>zs=YIcXiP22Ok7AufU-g~#0+ksq+d8N4AD?m~RU)q`Ec<MJGRO0!$>twOk zM*{;=3=CvUBEHaGlW%fQo70_v=2?ZqP2H529j+R{7J)-jSXcoU< zw6n2k>o$jw`a~W%@R8~#966jxGz>sZ_Cx~Fca@7^z{CJu>hrm3+in936R`gZtJBn+ zh6aKn*k8rKeTO+<9Ym+4^@QN$Pgm`50FNVyjY3@=!`uXtI%sx$jAqlWT<07fH2&nO zB0Q)_IK?!Qm06?w^XFpVx++1vLqL?AoSXpYroR%M9Ub*eO-(K4t-RypwV{SRd}w|zop{bq8R@QwG*r|0KJRoXL1qr5ITr z7fC1ybF1bGSHT*andxoz(*NatL=Gg#d#@&6(V(vT#xQHG=G$|TGN2RC0@_wKUttEd zvGBXn*pAVnZ!R;@6%pNHzI!C?xe!IGb z3V0I%c|L5^zSsV%4K#b@ON3)f&Y}4G+pl7*P+2~IRAK#Sap>&C5HVs6Co<->AZX&$)bg6dM;87s(s`XM7w-UtfP? zK+1TjnZ?4+4vchTGMZDcgf3CHWXNBjqP{Osor#Zl^XiqXjSVNT9ouc}9`(+=_l+_b z102wB@ueqt-8AF9PNm2f_lb0bLZ1M2i36%1^a|m;g=&BAf+HfxxVXBi!C>#PuQ%oj zihKM^z-EATcwFdHS67{La|hz&a)i6xTm3u$O#<2h!u!vBU1fJJS%8fwV?ml~y1a~d zm-cUM65zA9w$?bD6*>+;K*oUa!2c`V>?!o~6fz^#cZ|_R2^e6ocf0dfZra+ipie?W z3q8EZw9_etU5`wxYu*>d1?uv4lhmPzeJ&aXqxkdWsU9qTV9qB*+Vc`(f3cvu~Dv8O9e%0-2R zybjCypu=3=+}w!{R4x!Jn~F{03u_OGnhsP4-WVaT(?s^{odqY9QTz8l#l_8%UjI#G zbrOL+Bpc5UgG^CT;V3DgCMVyOfhLaq$Me?l^uuSe!udj+ce=Yn3g z^bc}I#_-%+F)$ASZFk4i6fTIn?cSS|lwBWI1z|9~DAsETRLQTfJ&6FmNwwXAI56u$ zu7iqRUgH?t?`C}QgUl_meBuU3Z7B zYw&l~G?fPrW9 z^rejrBFmKvD20SDbIJoAu{4T8Kp=czplaSlgBqyh1_1e>d?ewy>KaqOd>#@3E`yvI zN~04mf<;~}zWR5CMvR*fA3xX`2Cp^+-ZvQfDuWR`NcXM1J=4LI_}lvn`_aisE-MUB z&Vb}PV<;pF0D1jp1QHE{;2yWRnGfVJ82vBz#m^d&^cqAx<{$-p($&Rfe0DZaeTO5j zJ_i=f0H{;66x6jzYOV}CH}EUc_}Ez6O1nh4CgbIe!set-nGeE{q(zlB&~hFg&)nVJ zUlGi4=MDB~wv#+uRx=t-bC^@-j9T;z$dXLl3qPC)!QqJ`R#hNuXkcxC71~<;gMtc7 zGXG-%DTB6vZ-e}5egN^nmN53Xgiy96B~2A)4_zni8vBQy0!U8pL;4E{mVm=j?%*I1 zRzxa$ss<4g?u8ZH*0?VLaox4`|>5Ny_>4mBc=oeKdB`( zrX0C)Mi#IOf_hOruoJ%lXUS}1*8W5DP;cpYnVO@R0CM zqZl<5QYxxs0Nh#1yiBf|4me~tTB>Sla!{yHX{of%eQBo=KBvT1rl)3!;B;!TKpf_> z;!x~+2d0V(Qc?H;eSE3^D(?-6T$bu==5q#(p|ba+0prXkm;*~N3r?VoW)qTSsRaZC zOgvsiQ}DJRwt|T(cEPP)(;`W1ub7JqhMt~Ynvholm=4XjAR*Sp*kY2$w-l+;Yc;yR ztgG_?Wr~f5_g-1~`z{}%@BI9)nZ~tjvZU+q8=?Tj*1@lj<7MCf@z5$qAV^=!>u^ez u0hD)04sh2^KD+>H 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 Date: Sun, 31 May 2026 06:49:28 -0700 Subject: [PATCH 04/10] Updates example events file --- drawcal.png | Bin 9688 -> 8699 bytes events.json | 16 +++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/drawcal.png b/drawcal.png index 38a7841182cf2100b61314ca2b67a46ede4cba88..756beeed0bfb0ee16845915ce6ced5e614d2df75 100644 GIT binary patch literal 8699 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 Date: Sun, 31 May 2026 06:49:53 -0700 Subject: [PATCH 05/10] Updates example events file --- drawcal.png | Bin 8699 -> 9877 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/drawcal.png b/drawcal.png index 756beeed0bfb0ee16845915ce6ced5e614d2df75..ef3c7beb1b00d2b0425fae9d1cd2e0db79a82368 100644 GIT binary patch literal 9877 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 literal 8699 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 Date: Sun, 31 May 2026 06:51:56 -0700 Subject: [PATCH 06/10] Use explicit outfile in tests --- lib/drawcal/cli.py | 1 + tests/test_render.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/drawcal/cli.py b/lib/drawcal/cli.py index fcf81cb..94119a9 100755 --- a/lib/drawcal/cli.py +++ b/lib/drawcal/cli.py @@ -78,6 +78,7 @@ def parse_args(): help="which year to draw (defaults to current year)", ) parser.add_argument( + "-o", "--outfile", metavar="OUTFILE", type=str, diff --git a/tests/test_render.py b/tests/test_render.py index e140ef9..83a9387 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -20,6 +20,17 @@ class RenderTests(unittest.TestCase): + def _render(self, month, year, events): + with tempfile.TemporaryDirectory() as tmpdir: + outfile = Path(tmpdir) / "render.png" + result = draw_calendar( + month=month, + year=year, + events=events, + outfile=str(outfile), + ) + return result, outfile + def _assert_render_matches_fixture(self, events_file, expected_image): events = json.loads(events_file.read_text(encoding="utf-8")) @@ -50,7 +61,7 @@ def test_draw_calendar_matches_structured_fixture(self): ) def test_draw_calendar_accepts_dict_events(self): - draw_calendar( + self._render( month=3, year=2025, events=[ @@ -64,7 +75,7 @@ def test_draw_calendar_accepts_dict_events(self): ) def test_dict_events_do_not_draw_implicit_checkout_day(self): - result = draw_calendar( + result, _ = self._render( month=3, year=2025, events=[ @@ -79,7 +90,7 @@ def test_dict_events_do_not_draw_implicit_checkout_day(self): self.assertNotIn("3/6/2025", result["checkouts"]) def test_legacy_events_keep_implicit_checkout_day(self): - result = draw_calendar( + result, _ = self._render( month=3, year=2025, events=[["3/1/2025", "3/2/2025", "3/3/2025", "3/4/2025", "3/5/2025"]], From 33aff83db6d13443c2b2ee1630a19cc85add4282 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 31 May 2026 06:56:55 -0700 Subject: [PATCH 07/10] Support marker-only events --- lib/drawcal/drawlib.py | 25 +++++++++++++---------- lib/drawcal/models.py | 45 ++++++++++++++++++++++++++++++++---------- tests/test_events.py | 10 ++++++++++ tests/test_render.py | 10 ++++++++++ 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/lib/drawcal/drawlib.py b/lib/drawcal/drawlib.py index f1ec0f5..d506b84 100644 --- a/lib/drawcal/drawlib.py +++ b/lib/drawcal/drawlib.py @@ -293,10 +293,15 @@ def draw_calendar( event_color = colors.past event_dates = event.dates - first_day = event.start_date_str - last_day = event.end_date_str - is_explicit_end = (not event.legacy) and curr_day == last_day - is_explicit_start = curr_day == first_day + first_day = None + last_day = None + is_explicit_end = False + is_explicit_start = False + if event.has_range: + first_day = event.start_date_str + last_day = event.end_date_str + is_explicit_end = (not event.legacy) and curr_day == last_day + is_explicit_start = curr_day == first_day marker_days = set() if event.markers: marker_days = { @@ -305,7 +310,7 @@ def draw_calendar( } checkout_day = None - if event.legacy: + if event.legacy and last_day: try: checkout_date = datetime.strptime(last_day, "%m/%d/%Y") + delta checkout_day = f"{checkout_date.month}/{checkout_date.day}/{checkout_date.year}" @@ -314,10 +319,10 @@ def draw_calendar( continue # handle each day in event - if first_day == curr_day: + if curr_day and first_day == curr_day: s = 0 # check-in - if first_day == curr_day: + if curr_day and first_day == curr_day: checkin = True text_color = colors.checkin_text if event.color is None and today_str == curr_day: @@ -346,7 +351,7 @@ def draw_calendar( checkin_dates.add(curr_day) # check-out - elif checkout_day and curr_day == checkout_day: + elif curr_day and checkout_day and curr_day == checkout_day: checkout = True text_color = colors.border if today_str == checkout_day: @@ -370,7 +375,7 @@ def draw_calendar( checkout_dates.add(curr_day) # occupied - elif curr_day in event_dates: + elif curr_day and curr_day in event_dates: occupied = True text_color = colors.border @@ -398,7 +403,7 @@ def draw_calendar( # track occupied dates occupied_dates.add(curr_day) - if curr_day in marker_days: + if curr_day and curr_day in marker_days: marker = True # draw vertical lines between days diff --git a/lib/drawcal/models.py b/lib/drawcal/models.py index 1188d21..9f8d1fa 100644 --- a/lib/drawcal/models.py +++ b/lib/drawcal/models.py @@ -58,15 +58,22 @@ def format_date(value: datetime) -> str: @dataclass(frozen=True) class Event: - start_date: datetime - end_date: datetime + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None color: Optional[str] = None style: str = DEFAULT_STYLE markers: Optional[List[datetime]] = None legacy: bool = False def validate(self) -> "Event": - if self.end_date < self.start_date: + if self.start_date is None or self.end_date is None: + if self.start_date is not None or self.end_date is not None: + raise ValueError("start_date and end_date must be provided together") + if not self.markers: + raise ValueError( + "event must define a date range or at least one marker" + ) + elif self.end_date < self.start_date: raise ValueError("end_date must be on or after start_date") if self.style not in VALID_STYLES: raise ValueError(f"style must be one of: {', '.join(sorted(VALID_STYLES))}") @@ -76,12 +83,22 @@ def validate(self) -> "Event": if not isinstance(self.markers, list): raise ValueError("markers must be a list of date strings") for marker in self.markers: - if marker < self.start_date or marker > self.end_date: + if ( + self.start_date is not None + and self.end_date is not None + and (marker < self.start_date or marker > self.end_date) + ): raise ValueError("markers must fall within the event date range") return self + @property + def has_range(self) -> bool: + return self.start_date is not None and self.end_date is not None + @property def dates(self) -> List[str]: + if not self.has_range: + return [] dates = [] current = self.start_date while current <= self.end_date: @@ -91,17 +108,21 @@ def dates(self) -> List[str]: @property def start_date_str(self) -> str: + if self.start_date is None: + raise ValueError("event does not define start_date") return format_date(self.start_date) @property def end_date_str(self) -> str: + if self.end_date is None: + raise ValueError("event does not define end_date") return format_date(self.end_date) def to_dict(self) -> Dict[str, Any]: - data = { - "start_date": self.start_date_str, - "end_date": self.end_date_str, - } + data = {} + if self.has_range: + data["start_date"] = self.start_date_str + data["end_date"] = self.end_date_str if self.color is not None: data["color"] = self.color if self.style != DEFAULT_STYLE: @@ -136,8 +157,12 @@ def _event_from_mapping(value: Dict[str, Any]) -> Event: keys = ", ".join(sorted(unknown_keys)) raise ValueError(f"unsupported event field(s): {keys}") - start_date = parse_date(value.get("start_date"), "start_date") - end_date = parse_date(value.get("end_date"), "end_date") + start_date = value.get("start_date") + end_date = value.get("end_date") + if start_date is not None: + start_date = parse_date(start_date, "start_date") + if end_date is not None: + end_date = parse_date(end_date, "end_date") color = value.get("color") style = value.get("style", DEFAULT_STYLE) markers = value.get("markers") diff --git a/tests/test_events.py b/tests/test_events.py index 34e3873..d53650a 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -91,6 +91,9 @@ def test_validate_events_accepts_dict_events(self): ] ) + def test_validate_events_accepts_marker_only_events(self): + validate_events([{"markers": ["3/14/2022", "3/17/2022"]}]) + class NormalizeEventsTests(unittest.TestCase): def test_normalize_events_supports_legacy_lists(self): @@ -139,3 +142,10 @@ def test_normalize_events_rejects_markers_outside_range(self): } ] ) + + def test_normalize_events_supports_marker_only_schema(self): + normalized = normalize_events([{"markers": ["3/16/2022"]}]) + + self.assertFalse(normalized[0].has_range) + self.assertEqual(normalized[0].dates, []) + self.assertEqual(normalized[0].to_dict()["markers"], ["3/16/2022"]) diff --git a/tests/test_render.py b/tests/test_render.py index 83a9387..ae1e3b4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -130,3 +130,13 @@ def test_explicit_event_color_wins(self): with Image.open(outfile) as image: expected = ImageColor.getrgb("#123456") self.assertEqual(image.getpixel((163, 53))[:3], expected) + + def test_marker_only_events_are_supported(self): + result, _ = self._render( + month=3, + year=2025, + events=[{"markers": ["3/5/2025", "3/18/2025"]}], + ) + + self.assertEqual(result["occupied"], []) + self.assertEqual(result["checkins"], []) From a6d669f289fc88892da620842ed6df4a72febd51 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 31 May 2026 07:11:47 -0700 Subject: [PATCH 08/10] Adds usage docs --- README.md | 30 +++++++-------- docs/README.md | 13 +++++++ docs/customization.md | 44 ++++++++++++++++++++++ docs/events.md | 71 +++++++++++++++++++++++++++++++++++ docs/images/diagonal.png | Bin 0 -> 8942 bytes docs/images/filled.png | Bin 0 -> 8630 bytes docs/images/markers-only.png | Bin 0 -> 8806 bytes docs/images/rounded.png | Bin 0 -> 8667 bytes docs/python-api.md | 48 +++++++++++++++++++++++ docs/rendering.md | 50 ++++++++++++++++++++++++ 10 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/customization.md create mode 100644 docs/events.md create mode 100644 docs/images/diagonal.png create mode 100644 docs/images/filled.png create mode 100644 docs/images/markers-only.png create mode 100644 docs/images/rounded.png create mode 100644 docs/python-api.md create mode 100644 docs/rendering.md diff --git a/README.md b/README.md index fa7a49e..b66993f 100644 --- a/README.md +++ b/README.md @@ -30,19 +30,7 @@ Python: ## Events format -`drawcal` accepts either the legacy list-of-dates format or a richer event -object format. - -Legacy format: - -```json -[ - ["3/1/2025", "3/2/2025", "3/3/2025"], - ["3/14/2025", "3/15/2025"] -] -``` - -Object format: +`drawcal` uses a structured event object format: ```json [ @@ -64,5 +52,17 @@ Object format: Supported `style` values are `filled`, `rounded`, and `diagonal`. Use `markers` to draw green marker indicators on specific dates within the event -range. The new object schema is forward-looking; legacy date lists remain -supported for backward compatibility. +range, or use marker-only events when you only want calendar annotations. + +Legacy list-based events are still supported for backward compatibility and are +documented in [docs/events.md](docs/events.md). + +## Documentation + +Additional docs: + +- [docs/README.md](docs/README.md) +- [docs/events.md](docs/events.md) +- [docs/rendering.md](docs/rendering.md) +- [docs/customization.md](docs/customization.md) +- [docs/python-api.md](docs/python-api.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b2f32f3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# Documentation + +This folder collects more detailed usage notes and examples for `drawcal`. + +## Guides + +- [events.md](events.md): supported event formats, including legacy, structured, + and marker-only events +- [rendering.md](rendering.md): event styles, markers, and legacy rendering + behavior +- [customization.md](customization.md): overriding default colors and other + advanced tweaks +- [python-api.md](python-api.md): calling `draw_calendar()` directly from Python diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 0000000..46b7112 --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,44 @@ +# Customization + +`drawcal` currently uses module-level color defaults in +`drawcal.drawlib.colors`. + +Example: + +```python +from drawcal.drawlib import colors, draw_calendar + +colors.background = "#f7f4ee" +colors.text = "#555555" +colors.border = "#e7dfcf" + +draw_calendar(month=3, year=2025, events=events, outfile="drawcal.png") +``` + +Available color attributes include: + +- `background` +- `border` +- `border_fill` +- `cell_border` +- `checkin_text` +- `checkout_text` +- `conflict` +- `conflict_border` +- `highlight` +- `highlight_fill` +- `occupied` +- `occupied_text` +- `other` +- `past` +- `past_text` +- `past_border` +- `text` +- `title_text` + +Notes: + +- this is global mutable state +- changes affect later renders in the same Python process +- this works today, but it is better thought of as an advanced usage pattern + than a polished public theming API diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..7410430 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,71 @@ +# Events + +`drawcal` supports a structured event format and also keeps the original legacy +format for backward compatibility. + +## Structured Format + +Structured events are objects: + +```json +[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "rounded", + "color": "#123456", + "markers": ["3/5/2025"] + } +] +``` + +Rules: + +- `start_date` and `end_date` are inclusive +- both dates must be present together when using a range event +- `end_date` must not be earlier than `start_date` +- `markers` must be inside the event range when a range is present + +Structured events are explicit. They do not create an automatic checkout day. + +Example structured render: + +![Rounded Event](images/rounded.png) + +## Marker-Only Events + +You can also draw markers without a date span: + +```json +[ + { + "markers": ["3/5/2025", "3/18/2025"] + } +] +``` + +This is useful for simple calendar annotations where you do not want a filled +range. + +Example marker-only render: + +![Markers Only](images/markers-only.png) + +## Legacy Compatibility Format + +Legacy events are lists of consecutive date strings: + +```json +[ + ["3/1/2025", "3/2/2025", "3/3/2025"] +] +``` + +Rules: + +- dates must use `M/D/YYYY` +- dates must be strictly increasing +- dates must be consecutive with no gaps + +Legacy events preserve the original drawcal behavior, including the implicit +checkout marker on the day after the final date. diff --git a/docs/images/diagonal.png b/docs/images/diagonal.png new file mode 100644 index 0000000000000000000000000000000000000000..293a49999463d595e6c188d0801ec52592124679 GIT binary patch literal 8942 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 literal 0 HcmV?d00001 diff --git a/docs/images/markers-only.png b/docs/images/markers-only.png new file mode 100644 index 0000000000000000000000000000000000000000..8fba564015ea8b507606e7176a16a240f2acfd81 GIT binary patch literal 8806 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

~iQ!0NDj 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 literal 0 HcmV?d00001 diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 0000000..9bf2600 --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,48 @@ +# Python API + +## Basic Usage + +```python +from drawcal import draw_calendar + +events = [ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "rounded", + "markers": ["3/5/2025"], + } +] + +result = draw_calendar(month=3, year=2025, events=events, outfile="drawcal.png") +``` + +## Return Value + +`draw_calendar()` returns a dictionary like: + +```python +{ + "checkins": [...], + "checkouts": [...], + "conflicts": [...], + "occupied": [...], + "outfile": "drawcal.png", +} +``` + +Notes: + +- `checkouts` is mainly relevant for legacy list-based events +- structured events only produce markers you explicitly request +- invalid event payloads raise `ValueError` + +## Reading Events From JSON + +```python +from drawcal.events import read_events + +events = read_events("events.json") +``` + +`read_events()` validates the schema before returning data. diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 0000000..e9d6573 --- /dev/null +++ b/docs/rendering.md @@ -0,0 +1,50 @@ +# Rendering + +Structured events support three span styles: + +## `filled` + +Draws a solid rectangular span. + +![Filled Event](images/filled.png) + +## `rounded` + +Draws half-width rounded caps on the inside edges of the start and end dates. +This helps adjacent events avoid visually clobbering each other. + +![Rounded Event](images/rounded.png) + +## `diagonal` + +Draws diagonal caps similar to a gantt-style slash. + +![Diagonal Event](images/diagonal.png) + +## Markers + +Use `markers` to draw green marker circles on specific dates: + +```json +{ + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "markers": ["3/5/2025"] +} +``` + +Marker-only events are also supported: + +```json +{ + "markers": ["3/5/2025"] +} +``` + +![Markers Only](images/markers-only.png) + +## Legacy Behavior + +Legacy list-based events still render with the original checkout-style marker on +the day after the final date. Structured events do not do this unless you add +the marker explicitly. From 074d401b985b3e8f4f2a1b8b448e9b8e93f12cdd Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 31 May 2026 07:19:17 -0700 Subject: [PATCH 09/10] Bump version to 0.6.0 --- lib/drawcal/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/drawcal/__init__.py b/lib/drawcal/__init__.py index 066a180..ac60d7f 100644 --- a/lib/drawcal/__init__.py +++ b/lib/drawcal/__init__.py @@ -34,7 +34,7 @@ """ __prog__ = "drawcal" -__version__ = "0.5.9" +__version__ = "0.6.0" __author__ = "ryan@rsgalloway.com" diff --git a/pyproject.toml b/pyproject.toml index b89865c..1437bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "drawcal" -version = "0.5.9" +version = "0.6.0" description = "Python library for drawing simple monthly calendar images with events." readme = "README.md" requires-python = ">=3.8" From 56b4a0af06851c93e8222f5250768304a459171e Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 31 May 2026 07:53:03 -0700 Subject: [PATCH 10/10] Address PR comments --- lib/drawcal/drawlib.py | 12 ++++++------ lib/drawcal/models.py | 4 +++- tests/test_events.py | 4 ++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/drawcal/drawlib.py b/lib/drawcal/drawlib.py index d506b84..4ea31e0 100644 --- a/lib/drawcal/drawlib.py +++ b/lib/drawcal/drawlib.py @@ -258,7 +258,6 @@ def draw_calendar( past_date = False conflict = False cell_border_color = None - explicit_event_color = False curr_day = None curr_date = None @@ -292,7 +291,6 @@ def draw_calendar( if past_date and event.color is None: event_color = colors.past - event_dates = event.dates first_day = None last_day = None is_explicit_end = False @@ -345,7 +343,6 @@ def draw_calendar( is_explicit_end, ) cell_border_color = _lighten_color(event_color) - explicit_event_color = event.color is not None # track checkin nights checkin_dates.add(curr_day) @@ -369,13 +366,17 @@ def draw_calendar( (x1 - 13, y1 - 1, x1 + 13, y1 + 25), 270, 90, fill=event_color ) cell_border_color = _lighten_color(event_color) - explicit_event_color = event.color is not None # track checkout nights checkout_dates.add(curr_day) # occupied - elif curr_day and curr_day in event_dates: + elif ( + curr_date + and event.start_date is not None + and event.end_date is not None + and event.start_date <= curr_date <= event.end_date + ): occupied = True text_color = colors.border @@ -398,7 +399,6 @@ def draw_calendar( is_explicit_end, ) cell_border_color = _lighten_color(event_color) - explicit_event_color = event.color is not None # track occupied dates occupied_dates.add(curr_day) diff --git a/lib/drawcal/models.py b/lib/drawcal/models.py index 9f8d1fa..a89ecf8 100644 --- a/lib/drawcal/models.py +++ b/lib/drawcal/models.py @@ -81,8 +81,10 @@ def validate(self) -> "Event": raise ValueError("color must be a string when provided") if self.markers is not None: if not isinstance(self.markers, list): - raise ValueError("markers must be a list of date strings") + raise ValueError("markers must be a list") for marker in self.markers: + if not isinstance(marker, datetime): + raise ValueError("markers must be datetime values") if ( self.start_date is not None and self.end_date is not None diff --git a/tests/test_events.py b/tests/test_events.py index d53650a..1ed6475 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -94,6 +94,10 @@ def test_validate_events_accepts_dict_events(self): def test_validate_events_accepts_marker_only_events(self): validate_events([{"markers": ["3/14/2022", "3/17/2022"]}]) + def test_validate_events_rejects_non_datetime_markers_on_event_instance(self): + with self.assertRaisesRegex(ValueError, "markers must be datetime values"): + Event(markers=["3/14/2022"]).validate() + class NormalizeEventsTests(unittest.TestCase): def test_normalize_events_supports_legacy_lists(self):