From 7a0d6a933c3c642859c8a3bd596675435200c605 Mon Sep 17 00:00:00 2001 From: Nana Dev Date: Fri, 7 Aug 2026 16:36:16 +0700 Subject: [PATCH] Add configurable rename prefixes --- CHANGELOG.md | 6 ++ README.md | 27 ++++--- docs/assets/creator-toolkit-demo.gif | Bin 45000 -> 45892 bytes docs/demo.md | 6 +- docs/json-output.md | 8 ++- scripts/render_readme_demo.py | 6 +- src/creator_toolkit/main.py | 15 +++- src/creator_toolkit/rename_images.py | 79 +++++++++++++++++++-- tests/test_cli_json.py | 5 +- tests/test_cli_smoke.py | 7 +- tests/test_main.py | 44 ++++++++++++ tests/test_rename_images.py | 102 +++++++++++++++++++++++++++ 12 files changed, 276 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4fbe7..2046e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Added + +- Reproducible title and tag generation with an optional local random seed. +- Cross-platform-safe custom prefixes for image rename previews, applies, JSON output, and undo + manifests. + ## [0.1.0] - 2026-08-07 ### Added diff --git a/README.md b/README.md index e0aa775..89a3d97 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ or normalize a folder of image names from an interactive menu or scriptable subc | --- | --- | --- | | Draft a title | `creator workflow` | `10 Creator Workflow Tips Every Beginner Should Know` | | Pick three tags | `--count 3` | `automation`, `productivity`, `tutorial` | -| Preview image renames | `cover.jpg`, `thumbnail.png` | `image_1.jpg`, `image_2.png` — no files changed | +| Preview image renames | `--prefix campaign` | `campaign_1.jpg`, `campaign_2.png` — no files changed | See the [demo transcript and reproduction steps](https://github.com/HoungDev/creator-toolkit-cli/blob/main/docs/demo.md). @@ -33,7 +33,7 @@ See the [demo transcript and reproduction steps](https://github.com/HoungDev/cre - Generate title ideas from a keyword. - Select unique tags from a curated list. -- Preview, apply, and undo deterministic JPEG and PNG filename changes. +- Preview, apply, and undo deterministic JPEG and PNG filename changes with a safe custom prefix. - Use automation-friendly subcommands or an interactive menu. ## Requirements @@ -76,7 +76,7 @@ Add `--json` to any scriptable subcommand for automation-friendly output: ```bash creator-toolkit title "video editing" --seed 2026 --json creator-toolkit tags --count 5 --seed 2026 --json -creator-toolkit rename ./images --dry-run --json +creator-toolkit rename ./images --prefix campaign --dry-run --json ``` See the [JSON output reference](https://github.com/HoungDev/creator-toolkit-cli/blob/main/docs/json-output.md) @@ -85,28 +85,33 @@ for response schemas, exit codes, error handling, and automation examples. Preview changes to `.jpg`, `.jpeg`, and `.png` files without touching the directory: ```bash -creator-toolkit rename ./images --dry-run +creator-toolkit rename ./images --prefix campaign --dry-run ``` -Apply the displayed plan after an interactive confirmation: +This produces names such as `campaign_1.jpg`. Omit `--prefix` to retain the default `image_1.jpg` +format. Unsafe filename components and cross-platform reserved names are rejected before any file +or manifest changes. + +Apply the displayed plan after an interactive confirmation, using the same prefix: ```bash -creator-toolkit rename ./images +creator-toolkit rename ./images --prefix campaign ``` For non-interactive automation, pass `--yes`. Every applied CLI rename writes a unique JSON manifest in the image directory: ```bash -creator-toolkit rename ./images --yes +creator-toolkit rename ./images --prefix campaign --yes creator-toolkit undo ./images/.creator-toolkit-renames-20260807T120000Z-a1b2c3d4.json --dry-run creator-toolkit undo ./images/.creator-toolkit-renames-20260807T120000Z-a1b2c3d4.json ``` -The rename command orders inputs by filename and produces names such as `image_1.jpg`. Operations -use a two-phase rename to avoid collisions and roll back when a filesystem step fails. Undo -manifests reject path traversal and refuse to overwrite files created after the rename. Other -files are left untouched, but a separate backup is still recommended for valuable assets. +The rename command orders inputs by filename and uses a two-phase rename to avoid collisions and +roll back when a filesystem step fails. Undo manifests store concrete operations, so undo does not +need the original prefix. They reject path traversal and refuse to overwrite files created after +the rename. Other files are left untouched, but a separate backup is still recommended for +valuable assets. Run without a subcommand to use the interactive menu: diff --git a/docs/assets/creator-toolkit-demo.gif b/docs/assets/creator-toolkit-demo.gif index d8f973a8561dc5b84442e4ec677267a41ad99182..fe24afcd130fdd345d05ae4d22bc7a6859fd1e34 100644 GIT binary patch delta 18068 zcmWKWWmMA*6vqGCh=G(!$3Q^3yF*5Zq#`NZjWmL6BL_$cNSD$j-7&fZDd`502I)}u zzUSPJ&$oNdbDsOV&(IpUZwFkR;Dp%PKV8|{pI%&F>`$&~>XdKMk#Eu#?oySj(~_yu z%*ZQFOwEZ)&WK6QiT;>1;#imIdJqwx9*!tLL?*loiS`YS@O4X-DpVKGR27Mb3xzAX z`2;(<`^vx5=Jk=Wbntj%?P6@*=i(f)$|91_ z1jOV7#AJnq;Vc>g%;Nl9eB$)HoUELJPuTgMu>C*0G#qu}&Prer4hf4&!mTI^&^rz_ z3^9o6w#&;uOco7dJsZDA)^2y*hsWES|mY}tee%^g9OJ*Q=1-+}vO-bFdR7e0$Y*JF%?lxj@f5O#61?m5f*JTN|sngE#K zQP{fpdW3v7*Cd&8;Kji{CvV$3O~hPPYmA5*04Cn{d4VF7^1vwmSlzDcGB7T0f?u%z%E7hx1DOeh z?%|bMi}&+fyQ>I%9$@jN$4^?X=J-if;L7L}OIu-Kv9A{Qdi3mI=vm%62(jjrh126D z_B+fXaJwbf|FZ!Ezd}I4y{p zu&Y!`@uh>|B*^>#f27QB`fvCQMBnjcuaWf47?4N;3gVKWM5jKXhwrThrYNbJ7xl0r z4a@8AOk*bT#>lL2k&-ea3{aqcs!p#E23}?*z?&5e9jrBAfbU5(ppn7oimm!N;?qOOC$tAzP=*FxQDblwvWS( zbYBMPjQ+OqOv=_MqB315)fFm8wt?GnIS!8Ke@ue&`D4!BiN84efCO_3Fyl()VIedh z0I$tSW&)W3(EeT?A{$Z=h@-9($}A#-Te+$fushUutAI~gf)tG!(!oMc@Pta1hmX^> z-QCEtOfD4Rq=>~aKmC!c2Z9-#NsZ%|_0xrHR*JK*;)&m6$>a@UG%t&if3C@*X&}Kk3x=SIG zI4k#GzjfkF<0`Nv3^Rh*<0-b@U4Hd$aDGO+1DWrz_eGr4Fj!EH8WIpvJ_+OH>Dy$f zyP2t|NgLxCfdwWEIbGG|5xcgGur5wzT-9G}R4B^CEY7x4R+sTYoy}h?En;g08*$Pr zUHwLv#xlyAFvL|a-b$0L5MDPAtx&whwOZbmx^5}I=k+drv0}q}jXzdd<=a$Va$uL* zwoc5S(~jU?J#%to{Lbn+1Zw3(?dx8m(F4j*T-wyGM34Kg;v3e?e zJIvrKlw9m$$@%Jb&!$=y`i zczy0u#Ms^pbE{kiNkfUM=02VG-OO?Rg}?gPzJ8n3L`hsjkDP3-!P4Dqvsqn*pDFBh zB>NnOITM&lz^k!| z0Y#n=u)HKDp4pXsEcC@>Y3CEF(b3}=>QA?funh)vy6Zq(=Q5Y~&4gQ6Jiv4b=_{wc z*O;**ci}C%jYYZcuBG=V59v3XAw^;G(fGcGUVMC zqt&0jlIhBB?m`D`+u~}dM&cY-$t@E+sV4ss0~qSlTHY`Fao?ulijcG1?&plTut3-`DSOD z93^&jcgiAA!3S+iS#z!Zzs#a<$q_qp~+*G8Y!B2VcXlmjJ* zYcQ-q)!)wdolCKMzqkXhp#wiP!u}c53t}JIOl2RS8XzkX_FKi{iY;P$HuUeOFgevg zdI?{LG}OXXuw7APwIAdOM*z*dz3Fbqhe4Ppp0{mNuq_*M*dRg@`@e23a$)e@RS`O* z7#!f|?@4X1=kJlQ>qq17r9KqtsOCF3=kZ1YZE+Ru#1>5;9%wi4el8N>72bq8U<+Im z^LsEC@ZR2CgCmAQ)#nlWdr+}mHrlTl?bYXCCp;9I-0T|I6!V!1)IaB~eq&$O93Ad} zo|=ta9ST`!wl%tr-8PK#rHp$c7Pr$Jcj#cZn-;e;6ni8QzjG6Js%Ctx7=Nc0cf}F+ zn(G_Qc6$Oh~jv$kgRjdu1%Ao=g9ubD8+Dr%^*Dm@g|DRWFf`k_Nh6| zCmYA7)<&NkXr9`qe{zXpbzb=7k#4CdqdP#a&!}l#gF6S>ov0K28TTvloc9*actB$0x zu+G*bBso;iq?3Ikq`;{DVI68Dgt>;wJShH$R~q)4!20)%Tlt6&dSl%CH|USMaf zue_+i!i0dxRDT&?P&H-+VUi$J+rm4cU!+F9gd@08s|R7o+Svip#tVQEIfJXP8kCl3qd!V8eteYMbcu*yAElb>*siQIRzTqLI| z8=xGTWSCKSh9}_{Z*>@apEZ$CpQV-8t6nyP6QOL_rzte^OsGIPXu5POzr-pDp4qAt z|4x@hN7Ch--<-CHiWL-+^tyCWRBaK2@q&q-0vWVrUac87pd8TzUJB)cVJX#&#j=pQ zG7=r_7}t*5JRWdps)r_^!a{lK%&D z&eb*`*anl*0TnyyZ3om&M`zvZ*8G^}MW{Wkn0*8`R)@)&)REIwNsheKe!$pVR`qD9 z3X=>Mv5;B3t5xHMh~`1A_aOCU4J6XA?ohCC8H8`5a7$N`kFY_hF`A#nRyd3s(MBHJzLH+>J;`N-O9}JByASVNhA)m2iC`t)@eom*r&DE@mxJnk8 z06jWwvC7C?ippEFYH^U3IB=At2lAMtKfCld%LTUaVnC!!Ao^-B%{jj~kW&*NIV)7l zk}O)!4db2wWm>m$c;qsi0#gO8fOMG&_E|yK41`ZWbj5^G1gKoSw@F=Kc@+v4oq)(H zK>BH$PX+Uj>N?jlYkh^jCL(&02u-Sj-gus_`(+8*ZROx8C&r2#!0Z_>HidlDCVB&M1utH1L7u@wkn zd7P0c>e352d@7{TD^36LA$=d*#YO2&pL9@fY?A2wz1q}Lwy$3M-36jdai{f45>~S( zNhb~Vih(7yiTMQWkSO)P)(D|stFd_)6;^NejojDr?d zgAEj5F57OrS#SkpfsG0m2J_{{|F1Z!OTgYZD}|9MjWX z43n{^EeUerWnzLz=^%K~gp3-}Ll#Tg8lw`uWe}@7K&ov>bGG+p62cb<3zvdL-idvx zZxXy)l;khaco! zT=MSg7niQGLm<>eYE!qhhJ3WnrD86~iZ`erO9QrH4EsS3Ll_s7<_)V|jfmTf)bIX* zjnTtYN5(fMA$BYvBNj@{Mj~wu8fT8 zpwn^)?*s_(iLg;>QQn#kwm1V~)=}QlZ89nUUh@v7sSf(54pYpA8>>SKz6?CNYka%Z zuR3Na@CHIZBKG!Av!ycZr4lGHvoUeHR6Z8A`0l&e*hn%rENfKE4$|sQY$curVe#mo z$7oph=CnD(KsYs%2ko<)bLL06W0JFmfAq%>+aDn&K$|V?oUX6W2s&aqEY)3Dz8;LS z5zp_bckV1LR%OrVJOgteUx-djj|WYyY2^Ht?z)UwqP|*u+$TQCy3`{JBkd4-7`tp# zw4`kXVa8-V@u(b+h2dg@Fz+gdX8yhzkzF-1THP^C+09Qox;VnSRIkj}8X#Br%n4EW zEO~EeYa~NsAJYqd^_Z18q0nRm9Q*J30TQcwu~UA<205{KW9&AMB&|@Q~Y8 z_N!;4)nJ)wFun0*?29A2F^Hkuv{3D3LNCa<4ea{h%7OpX@_p50(5&O%YY+ax!sS^< zMs@W+6Xfxy8@~r8oKEz$&sh-a8OZpS@T@6{%YJ9bXy<(q>hDz|OH2WVYJo^57Q?R{1uzmRcAeZ!SB~~r>?^S{8 z;xP82WTm|vyK>qPFMxOaRQwpW$qj4N>|b*i-dKCOwFWyeS-#-z-(%(%Ir#qc=sRqZ zc=B8u+tkHz-30@>A-MPiq%a*|qi670(nlmS;-~s27SlgSnpZm-8KTZyF6pm+Y30bi z|A#}Xx2yA6ITogzp~u;mDwZwR(W1AnSN6KntUu%Rfqs=~2cHY!>%&)B=3_a^nfgb@ zjjk)5479$7%jvcotNob5VTOrV2H$j}Frh9cL>|yCN9q9I#G5#1uydvQ4htRx2FmiUQiMJvRGe z8U60*nl(0tvgIRTrth?Wj#`T4=9u359)q{NEWQ9H`|4GtJctx7poqX_tUIp5WegK|MnJN-G zAdVvlf9P|NQ2kIFF4(`KUT~m9VIC$pa6=n5qDpUsFe!Ry)vN(!a9mnf9k3fv82i~UC+{A)a$A)nTW7)F&5UpTwv}P zf2k?%Lo&_C_ESVpm-n1}Mps+;vijvK+lC)w`aYuiMl_+X3yFx*EcH$OWXoogAE{y5 z8HkuZ84}YAq2{SMGM+$iHPEs1#2=jyS4Ky6^Gu;Q=vllp?}af6S(9Ev6o zlKDK7N~;^DP+LTZ!D>`@N}OFg5LjYAnch)DA7hkJ~RjTQK`>YqsOEq zq`Z_fK%o&)M~daajK_OhDI^nAMz-lbly8z4C@ZbL^kut2tTg9A%xR#uTW&;ZZkq&a zIbo4hSoT3zr%AhsgSn_V1a3PTp@`x zcIWYfEYXUgIOI3$4iVA%>j04)CjW`QpakZa)blcbEjDt;Ahh*lJTSjuDOlPq24}4~ ztcP@v&ZzgV)^dK4K~B>e5yEMp$*>s0m}NN3YrO2*{L>8W_KoI|6og)`WXUYz>iFIb zlk_(a2Uh?I!XqM==(rqxI5*@IOPuC=yv5DlalIDG*m;A={oek0BKGGTu!o|`xml85 zUvkG#(VDCTz1D}_g})QnxIH(Ce(#I>XlQ+7hR%K^@~X~!s4-(M0dbV_;fe6PK6sqH z4gz;&-YaKV959euQT!Iz2{FHl1rXb;>E!S`m|Z z_Vp<2l%(T0b0=}Hmh6esuGOox7M~nYt%8gs$(>#j_fhrjk^*( z$0_6Q9!pwxR@@Bq5fDR0S@fxBLI&XPCINIGh9F(7HNy5Gi)Y0q1NWYqn!2ZvTgCPt zV4-^QX?aqA1{Di&-cYzhp7;9Q=ciO5!y@~A3FtQlUa8uv@yR64AXYxoyl=5J*pKlfZ-1`m`Mlr*dpxbLuw@c<>@~(qjQDZ^Qz^3KJ`}Qh%J?JgG}JTM z&xErzQ3GV*R~DZqz>Y-K&DGiC$UITmU1gajZBMc9B_cmK$wH;{B0#T=89FxLhU&w_ z(91+Pu7NgUu{CQ`!H%#boixXIqTmiwPMZcIwXy&s!gg?qm6kw6Y7wFh-6DCyw&jAu zlFb%B62s(fXo!408tRQ$bFue_?An^u6Ewyt0cO0`uk+Jibr9;-CcBncGQ8Ai&+tic z>~JRVE))91(}1t}I?LR4A7a49R4Lb-f-$@Mo>t|YEfch+8B@3zTqDMPemF!@`<%x& z%9Gh05ew}!93E%j%B!iGIa2ai*Fv`@TT(akc@$~o1alH9ua6DKrX2(e}Y%Un;F zK8>RLn%d}SPTc2AR!;XRi=jV3ehjsyCyB>^Fq1&N8!m6P(OgG-J9^+zLQAMh;H+aPSXdUPg z>+6g$eF}+{5I8mp!@M-b8btWDTG9xI4UkEmDQ#w2)oPt}?LIzLXVtKbuKL)HG0iZD z<$g%_s;OO$=hP0AWm~~C$MS+l9~{wMD>&GgMG~+%AW$Y&D_CLZQ@E`(Ot4Gi`@oz9 z;Rpi;)i=d zM*++@u$68@QyvuCgC94>vmq>%=6;;5hCU>}xn5;EKTcZwe$2`{R(-i)B<%M@;?xT) zW&w78**BM8dG=X6P%ex1E@JGeGX8|;CqiRQYspDO5dOi{X=>%pMuUyA>3iF;d{SoE z@M?GMb(?F1&E=ChttKp143E!Hd^=ox8`PVD-$+p2Y{|7H0w3pD4&>`wd-e7_GrYO| z@TJYve&-Xu^Vjnqh)E`Beaex_lyA|%ax=&E{%{M0m!@?_*x$4B!so{SJDb#YA%feb zs-J;zbES_2@g4Pdrc*}4?XIxIH2``Tu9*M zXener{pb_vY_fZ8+oCkIx|Pjhyc~Jyv2bsY7%yM2ODru_l{9{G|1Wzb*-mXfmZZ&J zo`J>X`_)$}Paj75pyhbJdy;o~rwBNyOj_!kyY%#*tC7%EE}9dyS`AF-&lO+hl-T3% z(#Huj|9F20$^VL*uE4I@_M1BWlFdWfQ-_+aNjSaAS!g6*U(ViQ_}SLbMk*q9ACb`6 zBVSP?Sju-ptlh;@?bCkogedW-i_W2+u~Vhd>@s^+?%q))Z(-H9F~UFhf2giT#pSx- zzgjGHcQwZ~?cDjvx@x5kI-ewQ*w*1|-Q~I|zttwVl*An9T#u6=kDDhyP~JQhBbNlo z#~jp>kPA;`2uGgEKED?bQQ|mwtWc&P8wX^%wUi8GK&N`7u`y6QtIkKl(kYP6>&Y(z zttmJX8IQj~lMhf#+ngOQdJw9Qo^>e6pfk!5p0b%NU5xEe7I+0~43yF(=ZQ!Yn7gYg zl#*c^4Vy2I&3KCC3qlLBiq-4(goQjK8x;|!ml4R4@dIblE%!j$pg0AL|6VlL9#Hd& z^a6(pjMe;~?~tA4P`bnV+6TQDVR`(DWFb32oBX`k*$Wl=H9#XP1`AqT~ zkiJpQ)1KetT&}YnP1O#CNo3xJfZ{Xyf9`Pw-vNkdxhZfC0|sS+liIflqCOQ?s8a;a zN;1l1rC;=^ics1mQ`*n;IxVB@KRml#d^Sk0q+1J*o~_=-?+b4KcFQEWDd#;ys8hg@BKbq&NvgmZ+)Q(9{x* zR=<^_N{W@+8gVA56<)~*ylX5Jrp>!Rf2FUgUQVmI041xxXt`zP`S^@6uYcm*=4i8rboRQA(aDna$6AtfRY9PwGdYy!6AH^s?FHTBQm7rOQCvG(a$dreVsx?Hzx0 zkvpo3bhKu8O!(avBGRykY!zg97)@m%nK^K#6@x)gUfQrUIBNgt*6MHhemzql9{r+8 zD&K8S)BU+N*U0E+4fq_v_aDOSy4>t%GwkX4a3SxUR-=ibWat2=b~=a+mqNR{0{(Y~ z@(=k~UX}(~;RIR~C;^+Xz^8Y4MEPii|M#zouNjiGHQhf4vt&004X;h2sl_bay(KL&Sg-uWst8pX0$(Az7PT#!xh9YP>&TFxEK2UwKkE{%?`%u%dO7R~xp%Jm?;7TUeo_Sem*7eQ>`mXe&y z0bqeVLx2fX?hgQDs2l(k-2jv^p#Tnmb_h+MHc#R)pKMEjyhJ@LF$AcQ;A$kj13Id5 zKBhAI!^(Wj5Q>l*0FfcVI-ocN**wS)RcZkOQ3EvdP+Dpvpa4oqSxmApN_}q>&5NY- zhvHQme)usTLpEPd3}o5Hz|!VRenKgrNKoEFsn1eEi7{Y-6#IxGp$32iWaZIf)?ee8 zbrjeipqv0vrp<#rV&bw*;u8R%1li>CCLX=iX9|6YH0pS25|MyF5pn=|DGQC4php0b zLaQnrJWyQAVLw zj2MTO$A}lxUC}TA1%@Kal}#8uObcUIBK;Sa6j0z2Qv#-i$UIQtkCj0Ra{<)6h9E&| z${R)p)QArPTGg=}Zip<$1$vcNZYhwdg=^m%%>THeANrg9apfVqSxehdDq7AjpP5DC0jvT9q5&*C;M5h_bjDsjjkn!QrKGO}j-Nu_A1bS`P2W`*@<ldh zMSclKlK@D-5Gj!ZrL{1(w{vjd+f56%#nQU1hfjd;s(!h+p?6qxO2`z(T) zR=~~6rA27#A}B>3vW*uYdcS2yvj3Z5KRRuvU)1Kt*2#Tmr)X!_Qp_<5y_T+TrTz&D zL!09%>>1|l`kEo2pT=z&*PX48OaScH{VqhtUAZ1ub z!pxU&4ZS}AsHy>U{avyEHQ(GrPXD zASJV)y0RK1wGzLwL6F)QT-k|}+WB2MoRB!AUODkoseUJ-KpVaAi~F4IRW)&y*l0N%uUu&O`*hS!erDtO2{f($igxxYl*qA0=!aj+vEpW9Ki~Y!wq0j1 zq@-R2VNOU&ZgV>VzKPy#|1iaiu>CN1TcT+?$7`Bcg{CDl{wsI$0e!oSdp;3+mzV8T z8MzvbpCEPuQX_SYFkF7%wiLBuO^EMAinneK1FGH|`KF@l@R8~GNL;i>+9kq~c-123 z7kYyjm>5U5q?#ud1>U`1hpsyA;F(N64n6I0t+Q_zblyL zy9K>IcG3;)I2(M74=lMeJu#^(QWm@|e-VTi*BpL3) zz*PUt^rhkX{l%EPQSYD~WUG)YWOzE0+^ zuXyzmbZLsGUhb2C3oC$O(TS`PZxHN9r?>_YWm$ z%&>1nHd5Du$uBK*T}6mn5=l&)ZqmGoYw^M(Lz-eVn(|WFOf{ zG~>P#C`NqJ29L0OiIuwJC`eRyn(>r3-q%ZbRwKF&C(#Tj0YJNXwuUyuH!Jqj&k6KW zB3dzKiXuch7*t(^q#nr{h5mFA9SQ>@5GB9BWIv$X-U)r!N?1&NiD5Y|rb_}7=j0K}E z2N4;vTby_0Vp^BVrrV5fTOlNgSy)Xd3%{FJe1()D=Vmju?ak3jz=`bSH}*OD-7&|n zD`Fb%gR*`roJ~I^*Wjaa(nk$T$C-#pRfthC5yQ_&TGGHf(Mj+E8_!)*$*V>#4J{bL zX8g&|rdm=C95UE@qP(kDP!Lq$w?>D2l<@;-$Xw#q;4|1p(oAYD;~nn%+imRgALgWH zS0&+{=eq*K7VMa~@PuEQ1?opJ=*wzx;Ao`^MH0k>!qY6-P=FUs9UnkQ1M=$O9FpDE#JATbJ!=V<(()rXe?i1R+fFB);!K7(CX)E{u?8 zv?bcPr_;F*fI|~Q0DHSrkaKOr#3lN2qU*14-f=sJitrtu|A1Ex?OzTxC9bK&kM%0_ zu0^UZ%BE6k>kIi1K6}sQ)2C9W>WjoWIJKToPs@yp6wCeP)CuL%qdQ6tk!9d|nN&7Y zDCEPY{9{Eolj}$6%Z4(6*kZ5GPCqI=8_La0+4Q+@e$;$ysIZG&$G(a$`cYTgQ0eym z;KP*q*M^>kD&G!nGe73JfJxD>!GF0eez(rOxb#Wtpu;=`1tRAA@EU94aK|kY^|Me{ zVYTwNDT5rJi>N+Q)XfC)I4GGU5B)2t25|wnLRJge6^-nXxH_LPy$dy+jS`Y2fT2P& z|K!D@=`PB9Ub{^OZUc+_k1rAnXkVD&NVn9EA0q6ubhV*g@L@_hs z@i0*lSI!W4kmg!Pw{WaMG6kaK0Wgut0;pKkF&J_keOt4urGQ4+3cv`?GFZ#3~rF zDS4g)^X*jPq)=&J@Nca@c+FWCz&LavC%;g5N9dkp%(dHVzWuxaG-kvctYzjTL~fsL z!3n%Y%nW50mfCA74Nn0$-YKs%+qe%BTLEf5H+SE#7i}G zF!FBr-4o)PH;S|hsM-}I4#UDxsF~rfG$50etwrj|E@tu=TjQx54=r?9?jXGUsLJ znauYMG&Ls5dVC^(^MlfzsXX`bvcY2mV8l7L_g^2|Fwb|Pr_&m#wnMB5(=CD^cgd)x z*G0+lZ+}ir#X3eTCv_h_of1;~7GbrSo~T_UG^{@MP57@xs&=`t#4o73)A$p|l>+=2 zgv|{??8{jx_s-tv^lg9dzq${ho?F#;rf+YlQU&Vv-d;%tESlIHWLOT3DaKz-S(9iG z3dS5AKZl~NE*xXu)P(){tvzL3dm20QUUR@{bBTq@&(0J(q~MEanFmN&?oP)}#D9NZ zV37$>m9H@8C!AdGue$S1?BFztc&zH?*?@+Vv z>|3QS0;yIOJKXOFH_Ys@EFD9cxIC7(NW(=cxt;XqiyegjCN2|BX-w zG{fo?USKIkj~G_|ky$9hnXtF!fbnQW)@Y^ws>TIi&4av&HHTZ|{eHqkSD5>tcJz+7 z|6usrX`bm`zw*~SoHzB^cQb4M^7_MWvPTT3vADV5ajdE1jp?`bxc-Dp4F{t7mT~)r zZ(F*#+bL#_L{CQltY+$sKKjh`hXFC*Cx9Ucd4grl|3lG7)yYIxYi4LkQwYa{p03@O zn$jt&1hvz>f6cV48AoKO&{+|&_CyQBL9)dJf4GyaQbN3xx3XPLQcJLLu&YV46PAY? zr%Cibt6us^ZneY2Oe&U0dfdOSH||9C`H7R(v?lzQ&S&DCT#ao&zku>EI}X9}$*S@> z^J=pd-IY~AU!Dv@e@@>T@c`vB9E{&Lb5i5HY@PVEj(8EHhrkc*4V3ENzS-Yu+Wr#O zw2SdzMe^$pX<7mX5)?QhIi$E#jKZ)4$yS;`wJ+H-SZ(kIsa$v#XetadAH^y<4CFIN z)2_+DtYz<*6;~(e8Z&e!kOz2`J~Mcsa|htcvETP=;A$0B!8kyp_%(Qf19?n;Nb?V+MR+Z zGn~=~hoqz6_b`s}uJ!X)vc*ChsPC5-`#OsHKVt#w4v*Kqx>JURA0Y@=TQ)+*Cp0f( zk+z=&9vK-I8nfZ+k27he+bYgX@ls416s_nCJr(@<(rB?zbNwk__mnYS(cAh${WDul zR@z({vNz*6R>9_p9C|aq`>e$5&IpQZ<7O3|vR?CIci=xq74sJld5Sc=?UbLA+k~u~ z9^p6!@7Ol7+1T1Gv=q4@#&>KVvAM3YxltCl?y$LE?d%^Fx#Jak2XKHY%?K{r2xYF0roas4^{281XRgQP1oSB`D^DUfN0~nQy(p>O<>o)~F4&)d05t5rykOPuo z41;H*0AA-LQo|`w--qj!RX6X!E6^79D1SZiM+2${>K zfENUikPlW^AzKriax(Vm$Bb!0YUl=Xto2?`kfgpkszi+Esn#|qWv;jg|5x=yFF*!(*u<>BpwF_WP$uDcgnO@zi)qdlZNq6u=x9`~*58@58}}3R2`jEhWyEMy{I8(ss(ev3EQ} z|H?=OT>3v&xr#$2oZNMs`N%NAb$pgv^At${-v`+s2~LSS^p95KoQZ+}JbHx{71!Pf*L8KA zRnM|QEw1HfM@w>*bH<1BmOQ!+*ow4@@ z*fswDUNB$(RlvXP7grrk^tk1WRFPI3OzfBS4!}u0+zwxImstV5JEhS3aaxObk9*3`qg{TDxL)a3X{;-ZaycuiIo}z7U2b&CXXd>e zaEC8DHq#=%?DOTE@$Ij=FB(_TJwoh8ybRmX>Z@=ANOX=m+_*^uD!>-5@Z>6u3KzwC z7qA6UjT!G9Ee3Ssay^bA`L>Tw-j~gW8NS10TVY6ADoMX#@FN9s;sj1#_O<@wSEly> zhdNiNmxGc4qAh;qY7c^3tj8!Jx5sHU>364pwN50NUsVM5|FS8NMV`Q`5!DY~@jzod z6#I}9;{a(NlHh}f=KKjB+2J}L_ap3S!5MEimjKzl0xh@T&j23t*? z%F{i?2bfm@C*1vZ@Y*tuzngns-43#0$cxX1bmcB(lsvzp1xw9N#<@J%xD^fy1g2>* zwVMh=+BNO_TF*W@6fWWmNzG|3A z(;1tRm!g0Rc&PF*KK88D<^V2GM(ti%e21x6JYi9)Z5hC6HZDU9Q{t2^dfXd3;fH$F z4(wOBeI>0vRk(M=PZeH`^Bmm~RvxN@d+>IsggvekAf*?P{pzKe?Db_9u0j>oVC)U| zaMkqY*QlSSAbi|SsKxHUVy2C8=lK7dC7j~(tPEOqguPi^%d{J6^>J{qW86s%Zn|unp;@- z!I#UbMvpQ#42Tt3&Bl%M3P*p=P4@ai#k{U6!-Le{TP^ZH&OOYW>dY+Wt3gwxuC$mi zRi6nfgOvnbPiL01-Ba9C&FCv`dt}HT*`#{3L)+>R+$&puSAKgj&;aA;CUjl zO|_5w%D@~K2KJ41j~nW$8sP#J51qqAdjalO7dH6U@C%@|2MM;pWE*>F8C;0jdveEI z2_0PKe)2M-3wIb5^KoyoUaGb+_Y&5|@fd{@B`e&AT*Hyhb|&J@!N~mxG2OKTB38`m zIM3QYL?ejFZ?5V}ukI?ewNVFiWr%h=G!z(caURa8mP2s6zX&t3?`wqtZgo8UWoN}T z9^vK^z5Dz4Ol6$$Od-}cxV>-R-(NjT@*B0owBnsSEcP$IQiw9}(3G~+s1~_Vxr!cj zj(j0x*o%crsy6zVc))qYf|C5O#uA7;=TjKs6fPi+C(?*~Oo74=M}5D74PLWzbNyPZ z#yR&2OTE-fJx@LXNH7ZM^9Jy;qkfkNMW!TCUQ34bel{ETw|&Q6@a?DR>w2g9=(c)& zFOLL75f1zHR6w;j(i1Md<1xRE$Uf)F5nZg~+H?0>3 z_f=n*JzPHC+sJQ!DB8Ul&#&%R>6Z{^@cdxWbl+EbXCkAff5%l}O42aWNFTqz(oO%k za;$&z$qBo5p!(X^FVXe6c0ko2fY7|>q>Ys7n7fD!y~{24t>^*%2h%izZI{WULs zWUDrnT>`yMnBjpx15F(e&34^ye_yuLxE0fWaRR!7-`<%uuUQ`~-G}`(=FgRLd%cG} z&Wtf7mGBuC4?ZF*JJaf{Lumh~_&<->w3Zzq-vw5fdP)6S`^D5GcgX&0Yf$c}HzjLU z?)ZM@=$G8d+|2Q{+-cp+3BLUA_?c5G`7`I4-|X_|P19%M@)s=A=c@9T&Qli#@>g?{ zm-h142Q!C$@;6B{SCR6!(*F+HUO$k)lyJPh`--@|p1q0DyXXJ$rwbwf2hruei-hW6 zAQBkhnpi>2Vk6W-F%vYL#S(L@VVODnWg3sG#*=D20kYc@$Z=LYNd> z$A2Buua?ShIgl)wWT%-a{&pv9I5dPXn=crLLMquo_lt_j{u_yqkPk3*>j+aCpCX|VgP{h=5okQsOO`?8jFj2ZezFwF{_EgGf zs)uE7fs227lpp1$7PHZGisgk062zOI&;h3AG6P zNAO(?p7~T<8$s0B_|sbbk4WyXu)K91M%q!ra{PfZg%HAd>;GF_tN@Co)@B{60DjQt zGzuptuQC!(J`06~M6)QuD-^zX;!v8hqC~$n5ra#%=RG!v`Ye#g$HUx zOJIZIo?BiGS9lehZs-wJ9|9iOffjn8fQEw=s=)!Pd&KSI zjODc#)`Hd^9b$X+?6@M;<)qbuka$>^?2tSModnzzij8ChWpKa-gjhbkPV|D-|4Yda z=ZAcX8 zfDU9ZfvrgL-878_|568FG$DUL z<*6}|npCCIQ3fH90E9qr&x7F5sSt9B_@1emXkJ7FBf?RzZZrbw7$jMV!U`1=60HY3 zMpNfBh*^hf5D+-vS#uSU72*1!xwfP+AL^{h&)jl!2d>Jov(VJfNu6Lj9R0>wTTa>K^sJz8IZxicFmHYp)m%o4g^{;v} zCs>@oH@>?9@5RJRSfu6`D*OHKgCQJY315W31rEx5C!ApoZX`Evn@0iCuZkmm~i(?-TnaD*p@{w}_&?_P4;z&zs)$w)eg9oo{{bo8SHR_rC!iaDfk;-~~7M z!4aNtg)f}p4R`p%As%&ciBFv36}R}sF`jXaZ=B;D_xQ&_9&(Y7oa7}p`N>h9a+R-~ zz#xPJj$o9v0kukVb{F8uC|ulwAQRq)|{i}C%>M&dY4rB8+m z75Z}dI)jhu3w%#fGV;=&{Y*&BO^7XyOUZtjl!1+V8y58{7#kntmm!_1AsMHN3^%?L ztnBqH%+33`ijT3dr`$tlFKc@@Yin;4OFMNNGaX~A+jdfX=8^`wZmQbGsw!5p0?L~A z^h!STEb%mhf&AmI|0l*bA3KzOC`*0t+MG{_s{u zOFR?9HVQ^rMG}eoih-t=N{qXv`C`BIOwqD$_rRu0DM7Hs12I)1T5B;?YD?#8shN#} z>eD&zF9eRW`S2c5ncqtX6t&+4^=X=@r|gAu|9IAqIFK>wBeBrik~fV+2_JDPFuFvs zZfHuIs4U*-gYG@`AMl;l5Z!Mz_7e^054%egbk7iw;NPaTkyPyG55Ytt8@1g~i>q2y zn8bc+7?{kG|JIFB67A)Cl7-aPVf#dPf8g z&G#M(`fSCpgKt*hVI%}+&=}52>FCw>fYJxQ)gG+3rkC6f(9@&d$$p7rz~+O|iULNY zy!)bG4G@YK3faNKrHc7+ABb;4*omsXcJ1>TDw@@&eP40!-p?0vIXUX0w21Ab2M>e0 zhE#$HOIE6{%WuD=$yf{y%9lOJ5RKY0%Z2*k!LVTjF-=()RnOZVvNT;Rl_Lr=MWg^T z)Q_ZV$Y3(-#ZqN_uw7m3`h!3lWB1i}{H6!9kD<*9Okm2S`$x>2ZKZW8mrtD!CM>U< z#MC+C-6PI3u{_lL-#Vraf|SdgsB5huY7#XsAFt3evvGeFm&i|a3u9uPko{u-A-{V4l6c^KC*8&iFRr3xyH4+!%z)97T-Y_vfVy9N?7W zze{3Vyc#4{u44#4@)~%yTB4W|wVto?ET0kls*&EF&e!r_; zg+c_-2)*%n&$;c!7>-7m#5S3wuEoC=G-}B;r@}m{3M-GZ#(~I;MMKxbbD&jIErZbs zkj#mBW4`nkyoSU z9T_5j_Ns~~OC%VLa1YIn7J7XmEw6Id(wiGktJE(wsP?qd;~e8wZ>V}z?M3=`?garC z(9}G#!}|Df@~^Ga&$#Pt`xkoiOYv_%6CG4PO&^-K<4Q*ide=PrP}#a@de-`rW7?mD z_V;$kSzG*E4YCzY{^u}@$!O^s6#RYY&uN8V$KU54!`6rR&d1L>&v)L29}O+s)I9of zLs^SygW^GK=QyLtCxMK^cw&X~E}Ep;sN4FBWVYws%%in0KRDY#L(hAZgv4UC^qpAa zntKHd>*5TnmKfWZ)kGrd65Y4+nJ3P_$`U8P@*iG?-!AJxD^q<+4u`IAv0e19&x)s{ zE4uJWUJNJ(NThyHbQUzb7!+!fNc*hlBq->tZx$v_AK2*j=&c;(MQvU&N*UhAP|TSgc_~;a`dkO@h|QUz>*F+XkDu zwprhkMxR9>&^ft!p_dc+RE-5v23yl)Adp|e>o7{m!4@~mbM19Kx!hNnuV0J9B-=XvxRvFxZF=!v4%*mzrcbae`f7GTsB)jP z)hevJE`;QI-!Y9L$yuYM3H&iD64+YbvdI*5MzqA?Tfbnl#jq#4aBcA9Tm1!INag|} zV0ccPe7z^H)kfLf8PN22?&s^*!PzBL!EMXgf6*b|>PMV0KA&tm_Y6o{@e?&Z{X-l? zOvbY0iXWdVea_vh|ERQ~lyp4Zc5~E+uAlb?PBCvgk4vB2+uEUc@pL(qSo2JQuweH( zFy(XGu3ytR#4=K;ya;sjCrb|&VMYh&G>2OJ4BX-m+LngUmxb1y1qqzFJv;~O zn_mvcM2%~OADx+UxI8b<=K;? zbvH?T;B8s=(Qhx}&w)gNm|&6sMuC8U=CE|8ppL+}=+hVsV{8CFCQUYen;84?4DD9N z>>j3t6`F~CBjam#J6=dE!s|5t>{Z*I-n@x*6O3eUo*VD9u~Axlk(F{SYsSoBs` z_+e?ls1|lBAhzMBFSUDu_RNb1R(>)bkRBP|Ul~3)*^s?ybcB|BCUbDs&mjE9Q>>Qn zVOqkOY)F`Pc#6QI$yhg^&)&qr$><5$WOvJC*laRYusc~x@^sKEYMVEVEbg~-+-BAw zOtWtoGvBbvy`kTH!yBB8kaJ@jc*EC{G7*p>Aeds(pCUo(b>||bOv*!&kooqr`5cTtS*xa8gePVdkUb=<9W zk~J>gIgyGe%cZ$xA{~R%JTHV?The@N1idfP{00P`%B2Sc3p@`_581pO+>#z)b36PZ zJyK5KkzB^hf%NF$jCj(FxR#70os0(;87V9RSh>u!Ow_C3%&Zo^cP*K@g1k8wnT4|g znQ~dBk9Z4$vnmF-%UiN)g1M{F7g?VKx$ETKH)V1)2ET9B;c97l-+6)Pu;G$Iff#Vw zUj(xXSim%Z1;eMrIUgU1FWL_?UIeg{l@n*xM5Yu|8G9E3yWH3EON(5~T>Z~_-a3sU| z4$hb_y-u1B*3Bnom5>wzzgy0OPe4*7%{}RO|H*-FSn?LT%+j4fTzJnl6H$7;yoZ+Q zg17=th{gAMnc)?P-z12API=l?Yq20ZRMK+iNB*Oud>TyAbYammUbt(CwC+lgKQE8u zVDZ7MtfxP6r9g^&5M5}-nBM$CZiENIj?85zDilOPI{3saZN2o<%eWP-1H7f-_n}ga{;@C9Zf057RBKcu>AF z@Zr!L-l(gVLIuy|%kIj6Gu!yMBwr^hMR|_y-+m)tBB6;l&Wfcy*_Wvcy&udZg@lh4kw!E!r`VU~-9+@yB8LJpEZt z%GOF{u?n=KycW%VnT&4DOsfJ7!eZ%G)#75cC!_}8sc~?vu5MLOL==biSoo`(Koqo& zgzl^A%Cq%p&>-rrT1&QqOMbA_3`|(8%fU%(%U9#8l?k}&mBmsP^-B7LTBa)aFJseB0c76c>Qyb4kjL^R9e!3?N+N(Nv^rC8cGF@HSgXTxS^!*zew?K8FJa_CKH!C9`TMufT2Ek%k6$}7aYF`%m zOGO@8>%IyBPnK6*xjdJHn5uygm=7geazQSl3xvbAM~{m(FLMIk!`myRJIPyce<7kc z5bb`Bu(9ZXGlbGJf8lS#>q#@Q_$p!a7|7u$hYwLGOIADexWoK0pChY;5&$u)uR7s^ zT!!Gx8MBonr1*>>N@EboS{1t5*1dx4J+&?&y}Ur$kB|I2d9xog4)Zwod~uxs9W-{s zyt?m&^*E>CWZQe%+4!CQL#}o@wR=HiMz;~NAq7zhWmWmv;5|z9yoWd zEXw%^$y`Z54j!v3H&$`77A~t~Cl*P~Otfqe-gHh&G_#n21CMj&%L^EN+iApl{Jrd- z&qD;-+m*;ZUD1{f3nC@yOCFh_i&h3dc-9wg=+!1YEZ))uO|BG&z8J`)uz#K?9nS-i z8v}D-3ZHp?2--5cZ6^9dui;HdX%$)3^f*X7AJQXVx&O5FV|(qHyy@ei8u_uZr~X3{ z{NI$sq}={h)MXV*>w-Ah;k$Am1%;|%n<`4%iVa21$$#23NYHquxbjOo~ocIdQC z+VrV77wDllU+$ZEayKnBPp4P=c!DYf6r{ycyp9rG2}ygbHWNnDpleWMnkdZMgOAiez}4@yy0OH^ zf52boMNJ_)&U3`*?LSY=!D87)O(F|lH5LHn46rgDZG$K7Oo!#-16%ME%IQ=$_$Mrj zw4G`6_KWVpi_FStteuMvvx{)}JA@%mk4-igl@n{t!oPy0o5gIdo&4KL4#PQ8lYhs7 z?xpL39DYMMtL-oGKlZ~p;!;V=7b+{TrRMv)_9|2$HD&a)@a_tAwVr%R?2(T?+8vGWbRJ_5p`WsUDM&#$CT&V|C zwW(eM1f|tx(^V*&72l+pzUOyaLV~n|Px0erv$gpjNj{>V^wmsxJ}zUsbtjjX=PTYv zz&SXE+K$niX?Nfa^97h)UA;dm(^U{Fa*##!LVQw%&hZCd)>3~@(f%UH&oBv=`YjRT zpMYA~%RzALV5PEhU5*dDBlmBcp;{-^c5Ykk&fLg{q^%7lPSu)ia)s{dEX7*GT)$_P zRTY(dQQuG7zmwZe{Z?LAHeBzg;$(5&y%)7hm%ob++uCv7`98I)QC_nXcc*mWW_60J z{nFqE?at~Fw&opO(bvw+-CQ_P-UNtHJKj06ThV`YbMJa|#+l=!$ma3q3+|5+Ad5rYwZf z+cR0)3isi^!}k#%r@S0aq1eRIQ3R<=O2gpHd&U z+}D8XeLM{pt-DnG`Fm+AcJCmjqlMY%?D^}XLo{~0Hsm5Te*A=Ncl&))hw?=1?x~Yd z`~B9M7ZDPP*;*o@O{Zb2HV*CI9?v}-y~v@Sq8>M2_$8;2XW>2y5=|DzaJIC)h4A3M z=u_56>~-!7e>v7WfC^vL8Nu5Kq8H=}=KA5e5)Sj{3G?swu2v+LP);j<>u^irzsnMC zy19w|wRPWU(f?ta??Q_Z)Rr$&oCv_88Xg(RfnZk;5+hQ1n}Xp+m*_Cvf7lttVzxG% zdB^fkZ|2c584MwNDA0wCgpxAI|D4P;-!v^X$We}Fwb(MRupn(J&a`-JP-)i@LBwXc zZT-o0Bv<48Dfe`>cO$n2o7L{a*5^Ba`rcdJwQlmc{uislS{H7%u1~qsrZV%8nqU0UGF(EY*?Ma%Ed&Poob!=rMMPH;N*_x zXQvf$ZLS#JVXdiHbqO6lYDqs~M0%8ju3(Ypc$47AUJ1R5is3CC(JxTR;l^$SQ+>(r z>i#@;Ug*^u$Sj~;JPc~^#r30Nt1hF4N@uCGM!0k6J!7uxc0*$|0`kew48Eil-*_8LM%8l^I18 z#Zm=$G_^erLy}Z-VlDX2i1uOu3$~OkDscA6REcvptXg|Tm{$sdKq5P1N$BR3P#`+# zQ|tnrkB1eOG7P>EO+mkL`GNyBc!sHwk)4jAlh{3jL>l5lK&>RS@3*1Dnzn^)2Wggj z*mFbFZY$H?)!xf^ew+XNS_iy$#0RN4pI$WGM}hrM-}$w@o0xn<^CQJ^j*upCV_BbX zcik^ubU6g6fQaas`j49yIya&ksd3h84KZ3A=WQ~zcA)v^>%VvVIrBXHJcPv-+U zwFADC&t&~dbUalGjtu$P`n10Mi%w8e!V{b0E}hSF7&=(@;^kHeN9eN#jv8BP=B8Hk zbjO`X#znD{_m*m)Wg~vfvy6WfzkZ~Q^?G1HZv90$>`SseaTS{mx-};dtGnR*Be<>Av{uDD#hUZF`t>)+U)i2&M)!90`#_rerTka#+E4;VHsAG{3y>&pVH^u%w9 zntV#kDy`DSp+4xmeD^kWlh`Ql?x*y@2G}a2mT2(!1j(=ME}{C_w#Q3&H`8OR z!uMIjM|DXgGGZzdmW88iwe_!a(Cw@DUu~*;sEWLz4t2Zt*G7jEHWozX&n4}+)*mMz z#-q?&+<@=vjE$m$P%ko3gEklCeo! zqiR<3c%&rgwR4936zgQ$4-2nA@5km-oJ-0~N%i!n?lRd- z-x6i&JJ)o!r|*aUEe0ejp?gKukiYION}N4<;Z>uar(Vw+QlthaM_qWD zKi#$P8KQEJGhEraA?U#CAfpfbWVo>Cdqn|wELOqkWh!eO+*|wl#%<-c!)KSKfC}Nd?T22AvpB02 zlro;)EOz-xdyvr$fT&eb*P~;&tBW(3i-)C|1v+cp(fd41P zCk+l2&PjxT4hr;@Y?tfWuH4x}9wi!^uN}W6ir$A-M+7Vmy=Fav4$s%^M6`bX`&hr= z^RVX_qxQt@@salkr3Twq4j-iAL6wWG?bMHi4E}sof{EgYb=P^V>vS#jvW2 z@&QWUPrJPKIC7zXk(56a`XMGB^CK8@%!6NMDvrs>oOlv zRcx%ky{kJ`9^KV6+QltpO8wjoU1;mBq&=4fMF_@xyy$si;gaeTenGw7L@|CkH?;7y zQqXCf=M&s<5KVNq_O_t&wxsyvx>e7Q0KI+2!Rf}VADQ4vTf`zc7g(K_6e$-JOA^0d%yz1LxlJCdo zr!SdcD9%}76-@3be6=caxxt*&!r63qMne5&+lCN=d@1MoVj7gWhvf(tAJij4&K5z} za~I04T&#BgxP~Vl7pkHfT&Kd-!#RUh6KGeHg4|SQ$BSIuZXw5f13jE*1px802;@@xj*Qndz&+F4{KKT zcgK&3xUzryQCl7#Ql=*_+fE~}p!ZF*;Hx59Jnpf|mtgi+(0}YM!wDjXTH%VXLjKWV z)v3>%-b44_2ev2B)oM^AOTTzV6DdMZh->IiaFL3CNT0Z(oh0%t2|AE@(Vxt#bi^e3R?U#ehm1VA7~fjCg0{}yxrL$fQf|G^{+GWeOTt1n18%Nt9RKQzCh7a5Bs zLjc_kdNb|%zy^llry)oH^Nk+XLh=1T#OS3q2+XVxss$?b&{%Mg(fB{T{&wBHY*IXo6VGE5p2%h>UqW@O-&s0rtW zi2wtPz~BvoTsonc|cgHg*`;09Y_)mB*x<7*KZ= zz|@aj{P!cV-}GKJOo{^ry!ugCJj7T#h^{{dNk2D}tHMWiJ*fAld2Y<%086P;lhS!)^T zVKU_vJ6Rmdpgm>Z_ur)kcN<4v)`E5##w+zLY58KkN-Q_oVbQK8oCX$^QDX#w@X3UH z!235$mjMQZo1D#O_%p;}8-H+4S%F|!c3F#7DBJC+AML3VF~ATH5ok0D#)2}9DA7T8 z47NV}fgQ8Pj)A!Q24A2>C#>zPqc~ti46uTnA0*7rQy0CPD}(E2j!l}(pZlJzJQ^3EXz-;>{kO0Ik9;spjvv&t$b|RwaLq9i~Xn{ zg(tuGtF2!O*hXS7UGHMy`@`U}2bBynu~QE?0h_iy+m7oYmNN9v0WT2B@X*OSHsoC_ zLm4p6i!oxi(24$)nra)DZ@W4?6~p%+(s*tU`p~n(I^cOMW63m(!;+AI$1*<0e0~0q z(EEVX_j~mTXr^R_Q)4QZBlg$3*aS-3xPGkhlm&pnOpeU1N9jf4f5qnivgQMr`?0si zhJG5%;jaJ8PNA)6?LgP`ur>n_8_o2elGp|)Fn=6d8U1jD!QoKK;ROS1RRqHj3u0^T zJbjKi>l^!AZ4QQgXYa?-BMgd%4Zfd%8W@1lDf_sGzh8PRjJp_edSD}e&vt0cbt=ux zd0TT@jBMrTf%+{e0$}DRKl*cGX-4(eME)$ldkECVp=n3;ujpfG1q?vGK-tLj_{u!I z423;0ndgJCdFXTa!a7oysHFR;-C!KKL$^+`6sx1RHcXB!771V8{l-~Xon`BVMq>}2 z0W5$Xs}GD#c`1J1scL2M=Gw8CX89BEGUqG()_c0bzkgt1AnC8VRGL#F%0`zfqf|8G zUg37=mt!cZX{YdpVW4jC?>2~x7$DyrG5izD0Q}H*8!#YFW?#iHH8I8I|V#eDS|HQO@(J0af?6l8_FH)-cHU_VXGfd{00hQVX22>|G82dt0M&1KDX zw{OjRl-6}^?MW`p6XNx!mNZZK*Zrwz{Lz~0&tKAeyR8T2-g^FWJy@AKxO6?#kRkBv zdiWoj(Eq~B0ETel4eT9vES!>zALG%-2YGZL_3Nd`;Q**|*ggrinN<4rb--H|8vQE$M`_Xw>1&h+ULJk1HYBq1nds6- z@9%lM*}xLNa?vw1W(hE0PF_9YGH6a7`&v8}Fg5qqN1qHec<0n)FIA5U1=8lnyo7JP zA;Nh4fzf#Aub05M`ThVL#*6~SZ#iJl)lnjj*jnkux*AK$=C^#2F6bDg7Zd^&?M0_j~qG;>%b?VxU^t zl#UT-dN)4q;`J(aVzUF*$PWXaoAUjh#U^_R*zFKVEN0r2T@qnli3snBJHVu+cdYl$ zSGP9?NmD>Az?6@rwf??G0Z?UtSOCyC`y%|^QjgLk(shdBl_l1C96X8!nfEy+A`B)< zhp1|?42Y4398-Y6RH}yEYhO_1GobS&?54wfoE|VB0t`J$C~6js#Ut^LSfHonpJ@ zVPr8sdaL%C?5rYvXV59Nprxn{U%S7?Ne3Q8HZj^W6afcJXe&mh*kB@`?GqR)!H_M+ zgx$#RkQl?cx33=T^*Yu(HrWr`Z!)toNKlhn%1qm4>+{RHh6xhv*^oa{lx9;IfW^M8 z;a(TEHfy0Tu$!Hq@_Wn4d_PB(u?9NXK(cI?F_`zHAwq3KUSvacVieehRUIHYAU z(SpaySC5G>!NS8$zDF6CS{!k?9|_#uA-g*PN14`k#43&+r3_jse}>)v{$OQiZv5Ze zc(%Q1AS&MDn8oP%y_&z4>nD0Mw}vLYV*7uX1v&w>OC^*5v}R~^)ni=ogp@O2u7ixe z?;&_>-kK20j`sKwH4U?s`O}~Cu(j%8e8>E-_wYz#Kr$@gRnW1-bI_BRvsU^*7pq%C z&urTn4^X9s0vZ-HVopC_+KZ83yaJC2-VP4M-hbW&In%E0Ifu{R*Ro&j^IjvGS?@SZ zDmzPTKD(zm8g~{*z32D3Ru=?dA-P6J>J}=muH6sVnxXmqKSG7xc(f4Jv(zuzFs&~S^z~{LJaY|l$WoVgP)6r#04D} zf5(A$LSR00E|j3s5c+T$GnbG)&zX>_Z_)9LdTgjrC(w@zngqX2y}P6&c&8`OB`;>3_&}H)#=x1VB`WmwlTiBqU() zJu-ph$V0QSLY?<_Sb{{?3(~|NC*`1NcC@C7BtL8JT*>U?yJP6?`W)A-th{MseX{#c z?s#MTgV{%)>r2~}!OzaFN!JSJ`%B;6-hFcO&wporCRA(hM(N-A;rhhKyAQ7~cXoD; z_Vx((F(5Jlc*F(Y=LHO@P&48MrTjTchlsVAJ&NH$5Z6lK}{PPFx$J5l^Hy)fRYPJky#ZmXH+b87qtiik{_rZd^DF*Tv zys7d+Hau@Bz9KMyh)0ksAcOYxns<)vTf0;U9HN zAL($lf9>+7Y%vC&)RjEKqU7`^L1`kX7+2U2T`^HN=zgoQE!y(Rn1TUKLxU`4tW6zz z?_54-QgcVV7er2!8hS^Kqs%tTTj|;U9ox5zM5=CQt>z!C&pd}fpKT(9k16nww6>sq%06ZHryPdrPLa@5?r6D8Zcec^a8T~j5|fe)psT>7Q#-{XrZOPCJTm8 z^BG#sd(*Big2hsEWaZ&A#a!9p^TfG2p~SJ$5bbLwlEY2|GtreE@U_Cnt;E<~ED zay=)pl!Ds#HXCQ{!}$Pk!#@8M#kc41$^FDj7t%z(`Z#m9 z{0{K9MyvA8b^}CAkeoFd^@4&$Dk7Eqi9ANa_{(E*095=wDMa=2dgql#hYTm~=!gHu zWQvUEh@Raiku>H6vcKu~!VaFnIj^=GuY4mei)C6spDO&e39U*azG85s3e$M%8pNB$;2!qMG0V%iwMeNqQ5)TWZM#Vmugx6e`69{A5{}R+ zE2v9yf2gvlnhZH1&G+S1;kDJ>e*xsCTouyVrt`s6e$XNPdj!l zKh9A9GO6f`vITkgH1fUr#AY4l-ds#H_0R1dZj;ZxWjqzk5Gi3?2Zpip>@iCX@ zj}4;`nl$j(QIR&epfXR0W=^4b$xWE&TYyaXp54<#o)eYp+OLs6)c3Qq+ffL~$hTgG zNOWM2Ac3>wLaRs_YU^y_X(ypNQiQ{W{A??pZ@=vMG)?-B<*l%`hXg&mtPiB&`d4Bh z1Z{y+ZLA}hGe&37j{mf4kn~n^rnn~&$({&U*uC4difM{u@5O;{hWMLc0AzHUyzKF- zH7!*P--mc<5;3uzL#IrdxU@zfXLKY-Cn9yYU$n;YbyEJPCH^s>%jso@ zI$5ZyDMXAV-pWhm@!^?4ngAHE&A|DK*Q&OY+rQH8Ej}A`yV8v*d4CJhad+0j7~Gyl z{@AaDG+3g&P;oUennf=I^e`=spOF^b+e`B@HjSZwbzEci(@>RO&bP#MR>?VyomW-q zK2o%|g^EdfiEu50YMW8kA@?%T(uu_ypEh6HT~54GejW>d;?=7Nz&!}!wHDxBTPg-NqVkAMFNDwEbC#JB#10M(>3my_Y@T zHT(j(vyWw4Taf0K?WU{oEn%J)lCziX$NusmF_(k?>IP!o~QE?egkbNcd=V|iW z_LUa;mI1SxL$ji=)jQ}5Gh3>o%!!A75+CJ8{e8;c|7~AaKBN7PzH?kaC%B=0Uw+)t z<+xa~WAh7fKxfXKlX7>DO>^`Y`Du{LNtJ)c--FrKnVvfVU=^h;=lcqCJ&@Ce`i>oC zAqCv;J7+CdY`Y{M6#g9j$!PrBu@~`#Y=Lm+yo;J|KkB~Xl9tnXuVm)|sZIMbk5N#+ z9-;Fv{6qVySY)Fu93+O1F09rO9Sofb{4>R}vrDdQl zWB}6RqWjnDcoc|K4C^026Ap37SC;vSxkx8O#-tMd(U_o*e=THCi*S;BwYecSUznNA z>5+;)++5ImmIn%3(B?&BK#2}IUdUrU95@U^=prJp;K0QTmt3f@c??8z;h&&7u@7Di z&v_J_@8p6fq9p;y5Md7&K;USwM838x0-6#9t#LS-&9|E5ywvyxs^tuK$s^q+V6*@( zZHfXieq!>QJP;H?(HcWRyJ$3t(81^HZ6m0T0jlkM(g$c5-FOU`86c+UW8QTjs1(o} zE(V)B(h2pFqL5&A9M}sdXjXM+tXjP*ao1jcWP~s}Cn;abFSOM4zXLo@kcfo*v zE|UCPq{;=ZMIEjR5d0y%Jep2KH;Z6(N3CZIr*C@Mrx!Vz5ClgYlO{LudA@3~qpJB5 z-vchRd6kne94D;WD>%mmzlnlK7x2$HY4tCuJuBqB;rfsXaKLfgO(>mxK(erql%9&* z2P1rmzxuEDbzUfx<#=beP*vOcpN0dCJU1^t&)rgImiR)pPA-rof!%BvrFD!v)yM-s zL@-98j#P?Np-#}6MUjv^YWxB*1=mqy0UE5C$9ai756%aVE{OeCR+Dg06Ix(V?G;Li zQaVH_^Fs-ONQFBNU;$?l+GRCmb>bwvsPuw1D^kaAPRTRxeo~Y;f3Nx-E^&T!jne{& z(G~tC^@{^MEDaA8;!#~%=6t!L_toX_0Dnm2Xyl1EQgvnPn`U=iL=8JYb&UA|CJN zs;Z8f!2!9roieE$coWPa(mRh^60Ch>bCn4$HpngZCS9!xvPto3;x`u>^X6n!Y7Yt z36HY@SJeyw$f0|n&%%Qy#0&fNl3t!~EcqTwgmsG=muH=8hqLQisq5O?_)A9|C+mvV zPk!n-0ct3ZQ7PKhQ#tBgP2R&S0qU{!<7n=;kx_3divRJIy>4Arn|DcjPypRs)y^oB zYwD%r?*gO+ZwoGPKIGqWoo<$LJ>2F_NWoL+hvD zk;;}+=_>d6j|29)fVFM zTv@#_R|p;8;$E5MNgR;}&c#6Zkp@|O*!W&VNN-Jmy4y?j^Bf=|_facEqrOis>%x>_9|AcG$h-~FMK>&f&b>TO%lbelG0lg5j9Lz zjOzC29Iwo6<)NA^;C8}go{FgNZ;ty3cNxZXp)Io@-5(xw^A1iT!ljF~WXpT~Hc6?r z&ES}kueYz>EIIxzJ}lqSisB}OBhN+zwS201s`&=#z4Jq&YFXE8>Ak5ps#W)^^#Gwd zGNE}^%zW0mOIlPM)!%kQpLmtB<~gtYn3|#>UiAZeJnuJqZ-1Aak7R39 z=;HOXJBW2+zTj!0m0Yhr)u^~TIC9=~axYxwjXPK8hQZqI?hRBQ0bjR;%?K!UgER3w z@VxY{_Esgm+X8vwBL&YpCx4z$Q}HX^Lf_rH+%=nCx8q*1V7#M~#j*vC*;?Wq)8r&Z zQ8OL_W13#|R(zfmAcrGf$z1@PHF41 z`RO9*OGJ)l^g^c?^`P%k{2JwB0{-tIo>a}1X52kh!nZO>>}dAGLWr=w&tHls=v{K& z)kCG_tr`cK!{xe{PT;>{bTVR|ieiR0USM9JMm_}|QrGS#MtuJ+$#OA?0ynXGAy~wd zCqitM>cFuR$DvRJ|59_agJL%mVXb^xWBHW8cyRfsl*>8)ddrjhP*EXQ=snQZ;ytP+QkgyWgPxLPy=GLF2xThC_oUn~vs_2CXA)Ep&tSkhb>g z2A$8^I(ZGcueEhQHt1Q|seEbBkI~Wl-eAyUtH01-$fINMTALh+p<*8}A~`X{CeJV=X4*5e!s7AEu9?Vh%uk$#mi*@+S}UrRxjOBl06?22l5f$b41> z&h##j6xwKHM8dd(gN!G&2FmCJnz)SN2_##2ZhzaD%p3=`2J%#TpS7L*7jT)71W@?m zUVPJhabH%p?mw?$=)dZGf1-pout`c~CUGxk5iUn#nLu4MTi#5h<)5 zQ`xNAd!Be-E>*InVBy(uLo+o7^OR4$DDPC9lgOtHP^w14`T^=H3^e;Z>{g^zq0PJh>gD9a&}Tp_Nn@#h>xVPLif>o#!o4+t z>SYSd)xSb$8A7$nu1eUhGWsR;?z$8BAbQtM{z!KD4tZpjc0q zHX50e3e@ucNc{XSp4@;;AJD1=&^+mR_3ahNR_fG{A9_ftVE}6UY5-lN_=@h#c)nTS zIp+N@oa%6Mu^Mt0XptQNn$YT$P^FGMg|vmhSIa@eGjVM(SFW<{Q6JVZL zjwYR}oLU%i4YFeSC+K=3k}^5_1--H(Od4HFl;j1>cgBJ#c*|-d;eJShOjz{S&W680 zaXOM5b-mtpQJ&uWi;%eW4U==!tcp2*{o)iBg_{Y5#y-Av_^o4Bt)uO;@@!1-iCVy~ zHt5N+Z0oxDl2O1hdB|CUDV7jU4ZXn+;WmtiMvgDfS@YmJoJ8(8$Es0Z*QBFnz#6s zB)kFpr(%*-5hPR+A?Ia)Uw$+V(d|@Be?bwFh7p>U;VUN4eT(s#KFUOlKoZ3Oeb2bf z#q~0pncdVEoiFs(t&frBna4KbG~a+z+>MGtr9f+nQQ6N}z(i${q7tHITxQdt6`cpR zLiU>^Z{Uhv5HTum^Bg!Ox=&boZL3MRV3ou7V-wQB+Fz`Qh%K9)%E@6$`00H|x(|lA z@53>0Ylg;e1K@h@M7gX_PKz!M{8pXW!T#&t3$)N^pCAM|sWiRmjCG1@;GXE)qgwkW zRT2&fRc-vm#pl;8!V;1VnQB^Oq-F2mVVr0bB`H^O;nv+20nA4Px%@e4S0OR2QRF9q z?%_gOOMF2zKA`?%6_tQ5mW1*k5`Tv?X-SWZN(7@qG9BgjF_Og%Pr!e{x?oVyo3+Y4 zWh`1XNc<}cQN_X6p|MY8TFkDB6f(cPJ{44sd+I*bprCYmRXpP~F`#%nc_i|AAxKhN z2282guL6r+`Pg+({CHwSW--xhR8DeBg_wpv*)g9=9@%fGcrw4+rb6;ukcS3=L4wI2 z6c0y|1}vdE?vgJ#Mv?JqH<(1Zq@uu7Aw%fz2tDzsRD?R$al!ur=K>i0gG5>FGqg)H zg`mY%Vemnw77!HIgMh}>JxEhjbw*D%XhV%vj@%IqulwhB#1^0uTcA!3GINBq(GF zMlLxc?g))pB||?RYTcTdYZqteaD!9loq-No=%I-&+GypTdJ|@unf`(1qgB2c>IjRR zdZeD;{MqWQx$fHQufcYxFo~gCy6Kpowi@fOe;3l4ZIGpI)9ttEuG{Xr@n+lXvkT4J z@4o>LT=2mOFP!kb`Svbt!x?Yf@y8*LT=K~WLOgMn7N=bE%{lMf^Upy~&hpD=4&C(A zQBPg<)dL@$bd)k@UG~{&uif_Bm2>@dlVZo+_uqjJUii>;-`(lqkx&0#`Q@3fyLjV! ze{Wv;>8Y>Y`sSYBJ^Jgp@80|GxzApE@X0UV{PTYoAA0oJZ{Pj*BUgWY`020T{`gk&0Ec;uW!&MJ;ZTi(T~M7r_`tF^-XpWi;a%(U?Xx zu91yxbmJT07)LqIk&bn=;~nvsM?LP5kA3vx9|0LiK@O6Tg*4#3sm92E;D`6Q+S image_1.jpg -thumbnail.png -> image_2.png +cover.jpg -> campaign_1.jpg +thumbnail.png -> campaign_2.png ``` The final command is a preview. It does not change either sample filename. diff --git a/docs/json-output.md b/docs/json-output.md index cd3a13a..6263b4c 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -46,6 +46,10 @@ The fields inside `result` depend on the command: Each operation contains `source` and `destination` filenames. Rename statuses are `preview`, `unchanged`, or `applied`; undo statuses are `preview` or `restored`. +The optional `rename --prefix` changes destination values only, so the JSON schema remains version +1. Applied manifests store those concrete destinations and can be undone without repeating the +prefix. + ## Reproducible generation Pass the same integer `--seed` to `title` or `tags` to reproduce the same result for the same @@ -89,12 +93,12 @@ print(tags) Inspect a rename plan from PowerShell without modifying files: ```powershell -$plan = creator-toolkit rename .\images --dry-run --json | ConvertFrom-Json +$plan = creator-toolkit rename .\images --prefix campaign --dry-run --json | ConvertFrom-Json $plan.result.operations | Format-Table source, destination ``` Apply a previously reviewed rename plan in CI: ```bash -creator-toolkit rename ./images --yes --json > rename-result.json +creator-toolkit rename ./images --prefix campaign --yes --json > rename-result.json ``` diff --git a/scripts/render_readme_demo.py b/scripts/render_readme_demo.py index ddce388..9290bb1 100644 --- a/scripts/render_readme_demo.py +++ b/scripts/render_readme_demo.py @@ -80,7 +80,9 @@ def _capture_scenes() -> list[tuple[str, list[Line]]]: title = _run_cli(["title", "creator workflow", "--seed", str(SEED)], cwd=workspace) tags = _run_cli(["tags", "--count", "3", "--seed", str(SEED)], cwd=workspace) - rename = _run_cli(["rename", "demo-images", "--dry-run"], cwd=workspace) + rename = _run_cli( + ["rename", "demo-images", "--prefix", "campaign", "--dry-run"], cwd=workspace + ) return [ ( @@ -108,7 +110,7 @@ def _capture_scenes() -> list[tuple[str, list[Line]]]: ("muted", " cover.jpg"), ("muted", " thumbnail.png"), ("blank", ""), - ("prompt", "$ creator-toolkit rename demo-images --dry-run"), + ("prompt", "$ creator-toolkit rename demo-images --prefix campaign --dry-run"), *(("output", line) for line in rename), ("blank", ""), ("success", "Preview only - no files changed."), diff --git a/src/creator_toolkit/main.py b/src/creator_toolkit/main.py index 310d818..e6cea89 100644 --- a/src/creator_toolkit/main.py +++ b/src/creator_toolkit/main.py @@ -7,6 +7,8 @@ from typing import Any from creator_toolkit.rename_images import ( + DEFAULT_RENAME_PREFIX, + InvalidPrefixError, ManifestError, RenameOperation, generate_manifest_path, @@ -45,6 +47,11 @@ def build_parser() -> argparse.ArgumentParser: rename_parser.add_argument("folder") rename_parser.add_argument("--dry-run", action="store_true", help="preview without renaming") rename_parser.add_argument("--yes", action="store_true", help="apply without confirmation") + rename_parser.add_argument( + "--prefix", + default=DEFAULT_RENAME_PREFIX, + help=f"destination filename prefix (default: {DEFAULT_RENAME_PREFIX})", + ) rename_parser.add_argument("--manifest", type=Path, help="custom recovery manifest path") rename_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") @@ -118,13 +125,14 @@ def run_rename( assume_yes: bool, manifest_path: str | Path | None, json_output: bool, + prefix: str = DEFAULT_RENAME_PREFIX, ) -> int: """Preview and optionally apply an image rename plan.""" try: if json_output and not (dry_run or assume_yes): raise UsageError("JSON rename requires --dry-run or --yes.") - operations = plan_image_renames(folder) + operations = plan_image_renames(folder, prefix=prefix) if not json_output: print_operations("Planned", operations) if dry_run: @@ -158,7 +166,7 @@ def run_rename( manifest = ( Path(manifest_path) if manifest_path is not None else generate_manifest_path(folder) ) - applied = rename_images(folder, manifest_path=manifest) + applied = rename_images(folder, manifest_path=manifest, prefix=prefix) if json_output: _emit_success( "rename", @@ -179,6 +187,7 @@ def run_rename( FileNotFoundError, NotADirectoryError, FileExistsError, + InvalidPrefixError, ManifestError, OSError, RuntimeError, @@ -254,6 +263,7 @@ def interactive_menu() -> int: assume_yes=False, manifest_path=None, json_output=False, + prefix=DEFAULT_RENAME_PREFIX, ) else: print("Invalid option") @@ -287,6 +297,7 @@ def main(argv: Sequence[str] | None = None) -> int: assume_yes=args.yes, manifest_path=args.manifest, json_output=args.json, + prefix=args.prefix, ) elif args.command == "undo": return run_undo( diff --git a/src/creator_toolkit/rename_images.py b/src/creator_toolkit/rename_images.py index 237525a..61aa1a2 100644 --- a/src/creator_toolkit/rename_images.py +++ b/src/creator_toolkit/rename_images.py @@ -6,6 +6,24 @@ SUPPORTED_EXTENSIONS = {".jpeg", ".jpg", ".png"} MANIFEST_VERSION = 1 +DEFAULT_RENAME_PREFIX = "image" +MAX_FILENAME_COMPONENT_LENGTH = 255 + +_WINDOWS_INVALID_CHARACTERS = frozenset('<>:"/\\|?*') +_WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{number}" for number in range(1, 10)), + *(f"LPT{number}" for number in range(1, 10)), + "COM¹", + "COM²", + "COM³", + "LPT¹", + "LPT²", + "LPT³", +} RenameOperation = tuple[Path, Path] @@ -14,6 +32,50 @@ class ManifestError(ValueError): """Raised when a rename manifest is invalid or unsafe.""" +class InvalidPrefixError(ValueError): + """Raised when a rename prefix is unsafe as a filename component.""" + + +def _component_lengths(value: str) -> tuple[int, int]: + try: + return len(value.encode("utf-8")), len(value.encode("utf-16-le")) // 2 + except UnicodeEncodeError as error: + raise InvalidPrefixError("Prefix contains unsupported Unicode characters.") from error + + +def _validate_component_length(value: str, *, generated: bool = False) -> None: + utf8_length, utf16_length = _component_lengths(value) + if max(utf8_length, utf16_length) <= MAX_FILENAME_COMPONENT_LENGTH: + return + + subject = "Generated filename" if generated else "Prefix" + raise InvalidPrefixError( + f"{subject} exceeds the cross-platform filename limit of " + f"{MAX_FILENAME_COMPONENT_LENGTH} characters or bytes." + ) + + +def validate_rename_prefix(prefix: str) -> str: + """Return ``prefix`` when it is safe on Windows, macOS, and Linux.""" + if not isinstance(prefix, str) or not prefix or not prefix.strip(): + raise InvalidPrefixError("Prefix must not be empty.") + if prefix != prefix.strip(): + raise InvalidPrefixError("Prefix must not start or end with whitespace.") + if prefix in {".", ".."}: + raise InvalidPrefixError("Prefix must be a single filename component.") + if prefix.endswith("."): + raise InvalidPrefixError("Prefix must not end with a period.") + if any(character in _WINDOWS_INVALID_CHARACTERS or ord(character) < 32 for character in prefix): + raise InvalidPrefixError("Prefix contains characters that are unsafe in filenames.") + + windows_stem = prefix.split(".", maxsplit=1)[0].upper() + if windows_stem in _WINDOWS_RESERVED_NAMES: + raise InvalidPrefixError("Prefix is a reserved filename on Windows.") + + _validate_component_length(prefix) + return prefix + + def _validate_directory(folder: str | Path) -> Path: path = Path(folder) if not path.exists(): @@ -23,8 +85,11 @@ def _validate_directory(folder: str | Path) -> Path: return path -def plan_image_renames(folder: str | Path) -> list[RenameOperation]: +def plan_image_renames( + folder: str | Path, *, prefix: str = DEFAULT_RENAME_PREFIX +) -> list[RenameOperation]: """Return deterministic image rename operations without changing files.""" + prefix = validate_rename_prefix(prefix) path = _validate_directory(folder) images = sorted( ( @@ -34,10 +99,11 @@ def plan_image_renames(folder: str | Path) -> list[RenameOperation]: ), key=lambda item: item.name.casefold(), ) - operations = [ - (image, path / f"image_{index}{image.suffix.lower()}") - for index, image in enumerate(images, start=1) - ] + operations: list[RenameOperation] = [] + for index, image in enumerate(images, start=1): + destination_name = f"{prefix}_{index}{image.suffix.lower()}" + _validate_component_length(destination_name, generated=True) + operations.append((image, path / destination_name)) return [(source, destination) for source, destination in operations if source != destination] @@ -154,10 +220,11 @@ def rename_images( *, dry_run: bool = False, manifest_path: str | Path | None = None, + prefix: str = DEFAULT_RENAME_PREFIX, ) -> list[RenameOperation]: """Plan or apply deterministic image renames with optional recovery metadata.""" path = _validate_directory(folder) - operations = plan_image_renames(path) + operations = plan_image_renames(path, prefix=prefix) if dry_run or not operations: return operations diff --git a/tests/test_cli_json.py b/tests/test_cli_json.py index d5b7563..4e38066 100644 --- a/tests/test_cli_json.py +++ b/tests/test_cli_json.py @@ -47,10 +47,13 @@ def test_installed_cli_rename_preview_is_non_mutating(tmp_path): image = tmp_path / "photo.jpg" image.write_bytes(b"photo") - result = run_cli("rename", str(tmp_path), "--dry-run", "--json") + result = run_cli("rename", str(tmp_path), "--prefix", "campaign", "--dry-run", "--json") assert result.returncode == 0 assert result.stderr == "" payload = json.loads(result.stdout) assert payload["status"] == "preview" + assert payload["result"]["operations"] == [ + {"source": "photo.jpg", "destination": "campaign_1.jpg"} + ] assert image.exists() diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index f098b7e..9b16414 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -62,14 +62,17 @@ def test_installed_cli_rename_preview_does_not_change_files(tmp_path): image = tmp_path / "photo.jpg" image.write_bytes(b"photo") - result = run_cli("rename", str(tmp_path), "--dry-run", "--json") + result = run_cli("rename", str(tmp_path), "--prefix", "campaign", "--dry-run", "--json") assert_success(result) assert result.stderr == "" payload = json.loads(result.stdout) assert payload["status"] == "preview" + assert payload["result"]["operations"] == [ + {"source": "photo.jpg", "destination": "campaign_1.jpg"} + ] assert image.read_bytes() == b"photo" - assert not (tmp_path / "image_1.jpg").exists() + assert not (tmp_path / "campaign_1.jpg").exists() def test_installed_cli_failure_preserves_exit_code_and_diagnostics(tmp_path): diff --git a/tests/test_main.py b/tests/test_main.py index 6ea60bb..20ff6e0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -42,6 +42,17 @@ def test_rename_command_dry_run_changes_nothing(tmp_path, capsys): assert not list(tmp_path.glob(".creator-toolkit-renames-*.json")) +def test_rename_command_previews_custom_prefix(tmp_path, capsys): + image = tmp_path / "photo.jpg" + image.write_bytes(b"photo") + + assert main(["rename", str(tmp_path), "--prefix", "campaign", "--dry-run"]) == 0 + + output = capsys.readouterr().out + assert "photo.jpg -> campaign_1.jpg" in output + assert image.read_bytes() == b"photo" + + def test_rename_command_cancelled_by_default(tmp_path, monkeypatch, capsys): image = tmp_path / "photo.jpg" image.write_bytes(b"photo") @@ -86,6 +97,19 @@ def test_rename_and_undo_commands(tmp_path, capsys): assert (tmp_path / "photo.jpg").read_bytes() == b"photo" +def test_custom_prefix_apply_and_manifest_undo_commands(tmp_path, capsys): + (tmp_path / "photo.jpg").write_bytes(b"photo") + + assert main(["rename", str(tmp_path), "--prefix", "campaign", "--yes"]) == 0 + capsys.readouterr() + manifest = next(tmp_path.glob(".creator-toolkit-renames-*.json")) + assert (tmp_path / "campaign_1.jpg").read_bytes() == b"photo" + + assert main(["undo", str(manifest), "--yes"]) == 0 + capsys.readouterr() + assert (tmp_path / "photo.jpg").read_bytes() == b"photo" + + def test_undo_command_cancelled(tmp_path, monkeypatch, capsys): (tmp_path / "photo.jpg").write_bytes(b"photo") manifest = tmp_path / "manifest.json" @@ -150,6 +174,26 @@ def test_json_rename_preview_and_runtime_error(tmp_path, capsys): assert error["error"]["type"] == "FileNotFoundError" +def test_json_rename_custom_prefix_and_validation_error(tmp_path, capsys): + image = tmp_path / "photo.jpg" + image.write_bytes(b"photo") + + assert main(["rename", str(tmp_path), "--prefix", "campaign", "--dry-run", "--json"]) == 0 + preview = json.loads(capsys.readouterr().out) + assert preview["schema_version"] == 1 + assert preview["result"]["operations"] == [ + {"source": "photo.jpg", "destination": "campaign_1.jpg"} + ] + + assert main(["rename", str(tmp_path), "--prefix", "../unsafe", "--dry-run", "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + error = json.loads(captured.err) + assert error["schema_version"] == 1 + assert error["error"]["type"] == "InvalidPrefixError" + assert image.read_bytes() == b"photo" + + def test_json_rename_and_undo(tmp_path, capsys): (tmp_path / "photo.jpg").write_bytes(b"photo") diff --git a/tests/test_rename_images.py b/tests/test_rename_images.py index 3b864a7..1bbed76 100644 --- a/tests/test_rename_images.py +++ b/tests/test_rename_images.py @@ -4,6 +4,7 @@ import pytest from creator_toolkit.rename_images import ( + InvalidPrefixError, ManifestError, plan_image_renames, rename_images, @@ -29,6 +30,19 @@ def test_dry_run_returns_plan_without_changing_files(tmp_path): assert not (tmp_path / "image_1.jpg").exists() +def test_dry_run_supports_a_custom_prefix_without_changing_files(tmp_path): + image = tmp_path / "photo.JPG" + image.write_bytes(b"photo") + + operations = rename_images(tmp_path, dry_run=True, prefix="campaign") + + assert [(source.name, destination.name) for source, destination in operations] == [ + ("photo.JPG", "campaign_1.jpg") + ] + assert image.read_bytes() == b"photo" + assert not (tmp_path / "campaign_1.jpg").exists() + + def test_rename_images_writes_manifest_and_undoes(tmp_path): (tmp_path / "z.PNG").write_bytes(b"z") (tmp_path / "a.jpg").write_bytes(b"a") @@ -57,6 +71,94 @@ def test_rename_images_writes_manifest_and_undoes(tmp_path): assert json.loads(manifest.read_text(encoding="utf-8"))["status"] == "undone" +def test_custom_prefix_manifest_undoes_without_prefix_argument(tmp_path): + (tmp_path / "photo.jpg").write_bytes(b"photo") + manifest = tmp_path / "rename-manifest.json" + + rename_images(tmp_path, manifest_path=manifest, prefix="campaign") + + assert (tmp_path / "campaign_1.jpg").read_bytes() == b"photo" + payload = json.loads(manifest.read_text(encoding="utf-8")) + assert payload["version"] == 1 + assert payload["operations"] == [{"source": "photo.jpg", "destination": "campaign_1.jpg"}] + + undo_renames(manifest) + + assert (tmp_path / "photo.jpg").read_bytes() == b"photo" + assert not (tmp_path / "campaign_1.jpg").exists() + + +def test_custom_prefix_collision_is_detected_before_changes(tmp_path): + image = tmp_path / "photo.jpg" + image.write_bytes(b"photo") + (tmp_path / "campaign_1.jpg").mkdir() + + with pytest.raises(FileExistsError, match="Destination already exists"): + rename_images(tmp_path, prefix="campaign") + + assert image.read_bytes() == b"photo" + assert (tmp_path / "campaign_1.jpg").is_dir() + + +@pytest.mark.parametrize( + "prefix", + [ + "", + " ", + " campaign", + "campaign ", + ".", + "..", + "folder/campaign", + "folder\\campaign", + "campaign:", + 'campaign"', + "campaign|", + "campaign?", + "campaign*", + "campaign.", + "campaign\x00", + "CON", + "con.txt", + "PRN", + "AUX", + "NUL", + "COM1", + "LPT9", + "a" * 256, + ], +) +def test_invalid_prefixes_fail_before_file_or_manifest_changes(tmp_path, prefix): + image = tmp_path / "photo.jpg" + image.write_bytes(b"photo") + manifest = tmp_path / "manifest.json" + + with pytest.raises(InvalidPrefixError, match="Prefix"): + rename_images(tmp_path, prefix=prefix, manifest_path=manifest) + + assert image.read_bytes() == b"photo" + assert not manifest.exists() + + +def test_unicode_prefix_is_supported_cross_platform(tmp_path): + image = tmp_path / "photo.PNG" + image.write_bytes(b"photo") + + operations = plan_image_renames(tmp_path, prefix="chiến-dịch") + + assert operations[0][1].name == "chiến-dịch_1.png" + + +def test_prefix_must_leave_room_for_generated_filename_suffix(tmp_path): + image = tmp_path / "photo.jpeg" + image.write_bytes(b"photo") + + with pytest.raises(InvalidPrefixError, match="Generated filename"): + plan_image_renames(tmp_path, prefix="a" * 250) + + assert image.read_bytes() == b"photo" + + def test_rename_images_handles_existing_numbered_names(tmp_path): (tmp_path / "image_1.jpg").write_bytes(b"first") (tmp_path / "other.jpg").write_bytes(b"second")