)>v82;e5QQm*y7=D}VcL@G4xlAc9+JK_aThaX2U
zgaJiBQLJ8UunEEnqB@MojjO^*4Td3p;5BAk1zo+Upd4pk{d=@qg}YyD!j#MB<@-x}
zU-*abg{Xz)UZUanHWO{Z&F<&~2{%i`OOwx~I#LoVew1-KkH9y!MIy>DBduH7i-n!q
zXcR;4QK2(3=*BYl{lFP&^_vfKo|w5m&{=C<2H0{=M1iL;06?f7f|gDVtY%426Qj_a
z$2S`v@78wh5}>R5XP3zY{5M+iGQJ`pZsy?CRZw@x!acRP`0foU`gI*f;==)NNQXp5z6~fw-yaW*pLMyKWjw-Q
z872|WUes}k!k>|Py@04U3HlIT(eew@e$`2+b*qoa9$}Pgb|mN+vN}-pyB60XI@29LeVhM`)VpfxYu!vOOt_=Pf{bxfTU8*5-$nnHU1ev|gZXP)T2l|<6C8n@oV)y-Tthw(>z1R1t{Wa2n=c^Qx4
z@Av6nD|jC+2!LdexH(7Z6>~E$KGrSq0ZJo!5kcp}a#bIHu%7Z?P~tFY)wPzf__+&Z
zxa=H?6^9m3N$)J}tE&jt1DuD`MI!aNR~orXRuCF7T65D*EGQaJYGhK-W2FPaA(|{m
zh>r{|9Rs7)ZcZwOw@y}N!=JH@NN9WRg_e?}GwriWhd3-8n*FJ$iLLoL`TJ$rx@U;u
zJ`!*M+0fx(O@wkK?$R__&C%lk;3}giVBIFuu?{UIhI)fN`{jkuubSRN?OUuIsHJSkfL$WFr2{U)iZ7B;e#Ux
z4m)8zT0!un142#C^^+zuhW=YZQPo;3EKToLBNJG5rU!Yz7E}#E%
zM&X{QxWwR2L$|+>2HBx)3LMwVJ$?lP>+$#`be%s{6rQ+UJMW4NxmUMu0v0)9gDUmv
zha0)EKpDUP#0e)6p-8z9fR+sg-F{|LXzT8z#dAtBt}r*N;2q3cfI?QY=>b=8L$En^
z;9|y=g6pNZnhwSJ`+V{wL4MeG_8;Ojd|I~M836Xqr~+IY)46GWE`tvrYXOX
ziZS7I$zf?i6gue?R?mI1<$9@m3*K-BAf7xc@}c&NGk*lB6~P!Dd3|8-@0r!RT2bT{1H4}*t$kIYJPsq#?;zaCt
z^U+|o!JXFt!nTABqe8Wn%iXlt95-l;Iaq1drH($h!#3vnr!J23^#BO&t6zs|m-Dbz
zMM;~I7c}T!XCqN<*&iF4r;9p8{^pnvm&0YBX)YfAFfVm;rxAMUYLVBYqXn)R?1AN(
z1ss=_A8j1-rD3PXShH^0UpKg^(jF3k-Xm2^hWDk=5UpT6d{Wh!-
zf0yj_`+oM>Cc5!uD8(#47c{@3d*qhaO(k?xmQq4J!-f%9{ZqvkTlSmf64bBOdyCeT
z3@%Fzf{AoBF;o^lp>`K uZQLcbGd)DqzP3U|_Hswyxh3T!{OIyfBRxee)O>IZxN
zzN3QL+u~ov3GTh+>t0IuG<{DAAh~0_xjVMag*zhZe+RZ}%lAdS*RyPSzd#aS^B&-z
z8}mQ*Yo|HDT}?x0pA+{KO!*6TAqih-5Pi@KEur^0t{%LB9UY=WCdIGYL>G2m2+u
zGCG@jRyGOuA2FLZnkY5O3YnYR{$ZFMX*EoknvE%r&+c1b7tWd{zqC*cE?i6fOQJU-
zh^TmE*e*#QxykyXch&wdj*ss_4#j^@m&awoqoiwK%-&Hf
z*rr{baBdSXSM||58>vSV&!O6i(rwf?PwxyVEFpoRzRE6Z*S0u_R%*M-IGKL7e5Ao3
z()Nm1u4c59t0oB6V*x{)I*u{!A!q(C1CB6)pw*wt9{NMriAz<=!rvZF;9HCOAi`1(
zo4A0-;6%O3vXfcQ&0o3(h8JBFoi?_L$R!RWv1ZD&q{uSl!u%_m7XE{Sb#MDo5h@gv
z%#E^=V3;rW_3spDMaI$Aezadm$y2`fB6S?mr=W6)TOAz9VHm_^%sUBf=7z5XAqxdRKa>6^VIG#3k{Jb_j_>kxAec
zi@;nU%G06yDE^KuRHsjmS+0G`xNe%lj!~gW!tG5P96OUJk~+shvrQMz
zpWLg8e}g{O3V!hBtHp1Z!oqRCYji@+NflM?Fm1lpQGn$a26uDYIXB+AMPg=XlGNK^
zY=B=vNtVy!$!Q@gY-({9+UWqWcabPPEkZlr6ASqD+CHzhnB^$s`syQD?H^5l32Mq>
zG1F7m!&+pYGVi@9y#_EcS#}*3rCTWFA+>1u8f6=Q+>Ey!l9`XLA_$mg`r1d`(pZwS
zjT}LDoeDmi#aV5+ecO
zLTY*Q2&mw~;57uzZFm&=gl6leY6=F9LBDd~`wf6RJr=t@Y7s;yM)rYsqa>#PJioqf
z%|ue0el6exz1Ee1;YPP}@~|gVX7`|cbIHW~@1I0<#9{W5)+%OxBm=%LR`=XO7!x^!
zVY7a}Vu1!O_4uqoe4pR1O%EsK;%x9@?bUGXMRfph&1*v_eAUt3{GxLpk;wugP#@F(
z3=|D}XT+#%K`XN?nQUjV(7rdd<`!cp-B$d2?`Gga&V0k(;6*TQ)0cBM!;PWT{WQeq
zru*Zi@f&XkhT5ImLhGInLbX%dCQyjGj!f@f@DcqvT3;iovwxH@5k+izgazgMRL{xf
zZ=AXfowu#mzFwnZmo#H!>IuxWuS|gb*9RR0;W+?GZC4@rlasq*%wGs%o&W6s01^g@mDMv@7g>@dpF2QUfv35$Cp
z6DOg5JFgsboXnB2`BvDC1|0D)XMpD0Zbqsc32
zd|JI~DjC+jg*Rk$$P7pdho`iQF)4=rw|Rb}>OYW#;$Om_RX6UJq{e$lcU=*_)-^WX1cw@sW^(Z^VN
zE*U{cX+LxaX1q5
zO7sI&ELoC8_%3apTaV^riK&>Z>9i>(TCx^9N(m7pi8IxKqD`*2f4(YlZam|g*ETA7
z{3VBhJ8|N~i}GH-O?#@_5OaAq$mPeyQfA6kj_%JRr?IA>7|x|A$)DSx-Cheyn|L}6
z<_|X}MxNL`o$7mZ7lS4;XSYv(GKjYyuCBzH?_Z)GWg?__+xot{4FBA{wU$)!NGsu-
zXEqvzl6I@06_}`{IQLqBO-bqk#FmX`3?w19ob?v7r=6%v28O`i)}B`fk|>U{{r1I_
zEqs1B{bj+DTvesSNq;Cvin#jBkFtN-yAab`tp;u?j09zzf83s7qlwUz__J21Q&dUJXnTy~bQ_y0Rg%-Xb&y(2ijjQ=q$~G1
zoc6n>)=lQ6dVq_sKeZRb*<(U*9ih=GN-GBu}b!%8$k2k|Y~#V6?8G#aXx
z*R{9=ClV*gDo!N1Al;ZM<5Qcxa)aSMmL6y3%+oN^mN@QoPP=s&6=C%%V-U{t=qkN)
z2ZXaU=fH|8u}#X7rkV~f+R}b0f*L{F*(Xr^>t;gJlvTs9SI`$`1h$ohijli@`fv7Q
zcLR6F$%#6pi#{(4kfuBb&Z)J8!dI6=qrQfC8r*X>oPZO>P%p+Z2;x-SwPdw>)kAna7H)5EK#jMi?f5prnK@O;pO!Cjisj*O~OR=(q!nEcB|80`3l_Y~2
zQRS@XZa2e*Fp%tjIOwJ_*D555Od!XKw#Q(7p!?bS%!&`^uBnKpEiodJ*wPj%WBL>p
zef6brv`PT=7fJ-{vL&-iODquYvHxv7+%Q#gb=b-x=wPuq*fVl60b3|KB;#z|gkq7q
zq_gSPcLRWZx_(|=y>`~X=HcpNm6_caq5eA*5&igl_0HCFcX5|%fonB!ybIfgY$8lb
z*AX=2Jcj>~@ao#6G5y`;k79(Bc1g4nL-J>=3wF#gYHlK;zECYr5$EkS!3R7GuG7a$
z-Z_?LC4%?5^v!sR(Pjca`;3$LH_AR4@$LCtdhuBV>Ds`G-IML$K*;d-$He-9WB~f~
z{mX3rR+RbZHUHnMlpa0-%U8dV;5$e+yH$z;fEp8EQtB7(=xw$FiWRLu5QwOsu+_iQu_tH*+HBicCP0q`WOpf4raz
zFqJ9jBQp*))*R(NW6Ztg43D9mr@_*1a;fQeDZzfdA6b@JaPZI%g(RcuUj1mC-?WEKk6DQOOCFIZK27m&{Dg_H
zFj$sjnI0pMamK04uG?(CD|i?v<1d>$)z~mDrQZ^KK)8R@uNxg8sWVvEwEr}SOYV5X
zCWEEhsm@|@eczq`d3{IQpz!ZY@8t?S=IQtD)?0;+Uu>?1yIjH{ojR3}XmE!lvP{K7
zz_u(Cv3f-5PEKq%@prx*BNYm%ATsO`kfb5%GQNLwkjXuJ8!IQUqU$tsX*H=pZcffl
zp`LlmrqZjYCxgcp?&!6ZvP*eV6(%39vy#>sGWz9`cZ?1}?mYKBUw}P{r=)K*KzEJS
zp73NgRIoJoeZYmKs?#mcWJn
z9NPm>DWTlnLt(DUM7~y)6^=@#i(hnUCB5cJF$qt@`T(K^^K&+Ka}KxeBqJVe;AM;o
zHD+n+L19)~QLJWEnej2|up}!UDM_JeqXs_D8m82{dCI=Lmxfqk^NkR@uv**#KRu|ApH?hv_|35)iDwHCbmMq0m5n4f)!bAMl#787IQTN{WMU?2a#^7<*
zt~4uiHB-0+x@NVq+!>;%(cQp=IMA$6wFF;x!g@gjJr#IQ#`c}JXM$2rE!Vc3_3R*U
zw)kgL(Aa8+LJs{nTBI$8{pSy*jEg=)PQEQyN*mC;rXh9%j{VgZ+%VPU2VN
zbRA})<;2vZo0M^Suq5UL(z;3TiS_Kkg#jKc)!(zv_sH_Av8qht6@dfS!+zIWsyzv}
z44irc{2=E^P||-(@z;9|kIh4%b_98FplS}D-(vkJX2yF-(e6?(T5AX3
zU?LnRJn9CqD>9M@%$A`Cu_FEcr`ULffprZ4yXVyhp|oG$Bw@Xpjqo=#%g8`rwY0B4
zoLXC|FR6TPK(0w4a$Xnk7uFdD(FUT(9mR9KAJWXvxh>(`U#da2+Ow6>Sks%hc0Adv
zW+SSxy&Mi4BeFu{TnZE-z3j1yryF2xC)xM7w6FaS(EKy~SrsipS7EGE%;Ej9%qQ_`
z$*Ud>jORAnU}OA9@VJ@|RIZfZ6p*1x!pd1L@;eviW|-K
z2K=?;A~~y*!KvvtqlGQ@`oinf=v$vJwl``8p{-usvT!TOy2>zmBNfXY-~8VnuSpB*
zeIV@2*Zro-+o{Q?H3J?bMiRkoGi(z+P>9~cP@{aqV|c5u&5!G}u@(>zvT2Z8I+Qn@
zP}oCZbmGzC7ChC5lOC*g$iQXM+R88J3CHv-%Eq&g)5N(UY{qbpom`^Ilv%uMdog3C
z-bq6^3|rViUys%NJuS-2sel=@enw#4N6;ec0B_j`DF}AoM^g3!lgrn;B8-%Me4+Rt
zs!cQP!|JHQ4&&&Kc-?z6%=R3njEOqV<%6JqL&4l7f342z#Pa)U2o8!4V-#2vHKF4n5F5)r75pjtE7?2GneZgCx6FSSW_$*skqP
z6WGU#6{v=b0SicwzM0moPmNcR_=Vj4#&?;6bRfHl$%FSmOAracp^P49JX71Qa-C})
z<>Ol!)t{e;gCGn7s_h_tlKoI9W0trYXWFmjBfu8oof`*WXBX0QnQKvLeA@3;U;&%_5!=+Uvgz%ug<BikYn%g98+~xSc
z8Pgn&F1X8F8M$&&%XCK!GYcnjhYaDl2KYyAT1Hn|1}`2S|
zNfP&;nhr@*#GzT%H09My2XDgiKF6b5pJvDF@vf5Um)t$^B7FmBWmC<+rUDG?@N5>V
z#24509vP>IvI_G-lthBEkwv93;?Y+a+orS{Tda9TNtrk=c}<)zO7Gt&*$|>wjGf3i
z^?rjc!FZ)ta2amlOSt@GB-aV*iS{z~wSAjC?U4`o9$;Cgb;J4N2R?KU&+p#kMfP6J
zvdH{#cEOF}y5OG08@k4C`{siNx_kBkkZy)JAKG$y2s5@2htukMihh&Ejc3%<^dkkk
zC4j(QbnCx{({pIonUX=5wtoXg_1F&6Jj!Q?0(2K(<|0Tp?F8Y0nW;uMce!iD!4#ro%25egdVkO6u3Jwvkcr=WbRw`W3^RE6>c;&}e
za*2gizp)8&5bt9V^mu|RPff0)#N(G5gG0mUm&LiDN&pd{4hmua<`FS8TZyeI#zZp7
zqd?P<4VOLKY{+;Qlz>(}UI9J2_7TdgMF&zJH(AbL6wFuRYzveh06qljwOSeIfEz?W
zboHXha8n5Vv}lIE;D-__tWX>%&%s}pm%8OH@kBsLW~#DG-i?e4x9mNaBdjT`Frav?
z-16N~TEFfM%hB?jTX4NFQ@RyS^Q>-nZP?iDBm!5~UzQHsm5R+kzoNl4gyXhd0q;hW
zoAitMXz$GJ2XV2jY@9I=U3WHE-9tSemc9sk^HeVpI(uG+>1U7}DzHI;X6HPTl{x=z
zlhh^Alb_8QG?43-vE|RTb;t6WAQ|F;{DcJfMCY_UlL2zc`xU~&ia;DOZv#H8TIV-P
zM9LPSv-jQN3bqTKcb)feEx%
zm*V!{Nn6TMv$(R*s%=CW!_VUu_3pDu)(8y@jzYq&%*}wS>r3ee?+`W>bRp}YSFA-^
zQ3hh8t764)IPrq04+NuKeZt4Z3ygW>1ac`5OBQCbor7e%?CD~hKNiwPVo@O~d|8(upkRO#hYCcuUcKf1u}GSjwuYcwKPS)8aU
zn*|#cF)+`w1QGgMpK^F?fNW23+ky~u3KsnG>gN%&8ZdeuIq;%h;}sR28k_g0U&{QA
zYIR?9@lkOWgrOQeY#1bJu~bv7q?4thf@pZN8_98+xuR*GGuT$2WmNe0#?3)6HaKhC
zp0eGT2{A2Ib;A618xq1pR5?=x89eKn*k>S=0Io~jDsEx`xFYRI2LDbB`i?X$yxsr(
z@LN@nFZ(+ptDWKDoM^^QdE1b0i{}|Leq^e*YQkIm?InW93(ky_ZlJ!sMYN&m1dp=C
z2Y@h<;2IKY%MpH~J@yc3PFrUYc3h!CMaiCT(pr2>%vn&L2~TeLKA-xa%lYH}0_5HF
zqEvSKZgt&zy{m1WaF{l$_yS%t#tA##O17lT`RX`e^-=yJYQ<>0B!RsN`jS_D3IrZN
zCBTmUWxzd<2^EGoTu@iQQ8PHUNf=I27?tr8s80+xVj1Re=!iMMflC;ZgXep!g&8TE
z);#Sb3AKug+RhFE)0P5_@3aNBmp$HK=8mDPGJ|#|+$SxG7^3m@-iVauP
zM@!R+0D@aIvPH|9F&MdGYtB(h_qtxFUX0x?-802}U~Qh!U}&klsg$EPUz5B_;QD?D
zrCfDRa0^*^-+Iw~LGrh}XBN)yB!-eLNa_w@!Xq3Lqi-p8GgWh*!*n5$Fc7(RY=Tnp
zA@&sQQ7oetZPy?eB0%U`iQeO7bI|p?LmsQ
z;6&&rJfIoCR5s{Ukd7{@xuxPVn`Nv?CTKH!xpFaBTI7+?ZOko7Pxb~M8j_y!&NoLLOSjf|O+(1b#*|-abXd$;)F+e%ZY8uXk+qzdV
zfywC;4_OjX9Qw@eHH$!(VjhT%nqaolT;1s<(bM*pd4w@kKAL
zDm~H-NH0;EC5z@DpGnl3Kio_;)IOL7he5N$3p11W`#qt5Rfi$u
zE6+U`GariXk8=#-cVQYLIgy(3YpPsQ7hgVl#lNn&(GAm(LH_hL7%%%Qa-IKRWPx&T
zqv}m%BE4|qA79v8;>Ij~aD5;AC<#uqv1eS$O=6K?1#RjAr6=HdsGIo}>P)XVT9;yw
z`fs3^theG<2lFbm!q}yxUbjJ5Bj#jX4yt&S6+n{Ggy$&tu<{}dL$HAX&b|nf!P%$dSe*Mk
zsz-R|duagF!+3nrN;hU91@iQ=!(?0O=$64+HAp^MQ8u*JTZT!2-r>d8a@oEsNCSrl
zgHC@`Dj8ky;%w|>a(-=50Liko#LvG#Pb
zkclo!vRAC;ji%(_d)2+AqX1OaK0pL}0f2TG_RcG!Phv3eVcTdRGm&v5p$
z4rD--qcBU%bv=wh&A$JC$eN(QaPNWC4S(W-cHK3{F94;yWH7ipCiV3*eQP#^zI|;E{
z@I{W8bxPa1X6Q|)`lnHpGR^&KZ&>yy1d(&1z-tF(_#
zHZyAq13w#MXiG)qB&inp%@$9)Q2ROlD?4o&qVCUGE)_5$ej)J$VX9cA4e-JPwP+JJFobH|3S>l{`*J*8WlwCX(dWLxAv@T1N6>k2?r{u58o<4l2T8^fN&
z_31+v9W7cq9-7meMn{)nC!YV!8J$u%I>}Ot-yEA#N96@7>~RX`Bt@-IaZHUI4g0B3
z|LRqo)Tjj@jQ@MW)MNf*<
zHdx1^H1Db6X^X6rIJHH^G2X`ifAEw0ijyq0>)B(r4|Xa`<+4^si{a>q(9kd+y{kv(
KZs_(uUw;E=Z=U1;
literal 0
HcmV?d00001
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..9e8303a
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,21 @@
+
+
+ Project coding standards.
+ podcast-rss-to-cpt.php
+ src
+ tests
+ vendor/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..797e240
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,12 @@
+parameters:
+ level: 6
+ paths:
+ - podcast-rss-to-cpt.php
+ - src
+ bootstrapFiles:
+ - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
+ - tools/phpstan-bootstrap.php
+ excludePaths:
+ analyse:
+ - vendor
+ treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..c2f726f
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,8 @@
+
+
+
+
+ tests
+
+
+
diff --git a/podcast-rss-to-cpt.php b/podcast-rss-to-cpt.php
new file mode 100644
index 0000000..be7c0cb
--- /dev/null
+++ b/podcast-rss-to-cpt.php
@@ -0,0 +1,36 @@
+boot();
+ }
+);
diff --git a/readme.txt b/readme.txt
new file mode 100644
index 0000000..a043ba0
--- /dev/null
+++ b/readme.txt
@@ -0,0 +1,50 @@
+=== Podcast RSS to CPT ===
+Contributors: rss2cpt
+Tags: podcast, rss, importer, custom post type
+Requires at least: 6.2
+Requires PHP: 7.4
+Stable tag: 1.0.0
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+Import a podcast RSS feed into a configurable WordPress custom post type.
+
+== Description ==
+
+Podcast RSS to CPT imports existing and newly published podcast episodes using WordPress's bundled feed parser and WP-Cron. It can use the included Podcast Episode post type or any registered post type that supports the fields you need. Imports default to bounded batches of 25 new or changed episodes and automatically continue while scheduling is enabled.
+
+Imported data includes the title, full synopsis, excerpt, publication date, episode page URL, enclosure/audio URL and type, duration, season, episode number, explicit flag, source identifiers, and episode artwork. Artwork can be added to the media library and assigned as the featured image.
+
+== Installation ==
+
+1. Copy this directory into `wp-content/plugins/podcast-rss-to-cpt`.
+2. Activate Podcast RSS to CPT.
+3. Open Settings > Podcast RSS Import.
+4. Add the feed URL, select a target post type, and save.
+5. Use Run import now for the initial import. WP-Cron will check for new or changed episodes on the selected schedule.
+
+== Imported metadata ==
+
+Themes and integrations can use these protected post meta keys:
+
+* `_rss2cpt_source_id`
+* `_rss2cpt_source_key`
+* `_rss2cpt_feed_url`
+* `_rss2cpt_episode_url`
+* `_rss2cpt_audio_url`
+* `_rss2cpt_audio_type`
+* `_rss2cpt_audio_length`
+* `_rss2cpt_duration`
+* `_rss2cpt_duration_seconds`
+* `_rss2cpt_season`
+* `_rss2cpt_episode_number`
+* `_rss2cpt_episode_type`
+* `_rss2cpt_explicit`
+* `_rss2cpt_image_url`
+* `_rss2cpt_creator`
+
+Episode fields are registered with types for the configured post type. Public episode fields are available through the WordPress REST API; internal deduplication fingerprints remain private. Enable “Refresh feed-owned fields” if publisher changes should overwrite the imported title, synopsis, excerpt, date, status, author, metadata, and artwork; it is off by default to preserve editorial changes.
+
+== Notes ==
+
+WP-Cron runs when the site receives traffic. For reliable publishing schedules, invoke `wp-cron.php` from a real system cron. Removing the plugin keeps imported posts and downloaded media but removes plugin settings.
diff --git a/src/Admin/EpisodeMetaBox.php b/src/Admin/EpisodeMetaBox.php
new file mode 100644
index 0000000..75733bd
--- /dev/null
+++ b/src/Admin/EpisodeMetaBox.php
@@ -0,0 +1,102 @@
+ID, Importer::META_SOURCE_KEY, true );
+ if ( ! post_type_exists( $post_type ) || ( $post_type !== $configured && '' === $imported ) ) {
+ return;
+ }
+
+ add_meta_box(
+ 'rss2cpt_episode_details',
+ __( 'Podcast episode details', 'rss2cpt' ),
+ array( $this, 'render' ),
+ $post_type,
+ 'side',
+ 'default'
+ );
+ }
+
+ /**
+ * Render safely escaped, read-only source data.
+ *
+ * @param WP_Post $post Current post.
+ */
+ public function render( WP_Post $post ): void {
+ $episode_url = (string) get_post_meta( $post->ID, Importer::META_EPISODE_LINK, true );
+ $audio_url = (string) get_post_meta( $post->ID, Importer::META_AUDIO_URL, true );
+ $duration = (string) get_post_meta( $post->ID, Importer::META_DURATION, true );
+ $season = (string) get_post_meta( $post->ID, Importer::META_SEASON, true );
+ $episode = (string) get_post_meta( $post->ID, Importer::META_EPISODE, true );
+ $creator = (string) get_post_meta( $post->ID, Importer::META_CREATOR, true );
+
+ if ( '' === $episode_url && '' === $audio_url ) {
+ echo '' . esc_html__( 'No imported podcast details are available for this post.', 'rss2cpt' ) . '
';
+ return;
+ }
+
+ if ( $episode_url ) {
+ printf(
+ '%2$s
',
+ esc_url( $episode_url ),
+ esc_html__( 'View original episode', 'rss2cpt' )
+ );
+ }
+
+ if ( $audio_url ) {
+ printf(
+ '%2$s
',
+ esc_url( $audio_url ),
+ esc_html__( 'Open audio file', 'rss2cpt' )
+ );
+ }
+
+ $details = array_filter(
+ array(
+ __( 'Duration', 'rss2cpt' ) => $duration,
+ __( 'Season', 'rss2cpt' ) => $season,
+ __( 'Episode', 'rss2cpt' ) => $episode,
+ __( 'Creator', 'rss2cpt' ) => $creator,
+ )
+ );
+
+ if ( $details ) {
+ echo '';
+ foreach ( $details as $label => $value ) {
+ printf( '- %1$s
- %2$s
', esc_html( $label ), esc_html( $value ) );
+ }
+ echo '
';
+ }
+ }
+}
diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php
new file mode 100644
index 0000000..ce1c0f8
--- /dev/null
+++ b/src/Admin/Settings.php
@@ -0,0 +1,567 @@
+importer = $importer;
+ $this->scheduler = $scheduler;
+ }
+
+ /**
+ * Register WordPress hooks.
+ *
+ * @return void
+ */
+ public function register(): void {
+ add_action( 'admin_menu', array( $this, 'register_page' ) );
+ add_action( 'admin_init', array( $this, 'register_settings' ) );
+ add_action( 'admin_post_' . self::MANUAL_IMPORT_ACTION, array( $this, 'handle_manual_import' ) );
+ add_action( 'admin_notices', array( $this, 'render_notices' ) );
+ $this->scheduler->ensure_scheduled();
+ }
+
+ /**
+ * Register the settings page beneath Settings.
+ *
+ * @return void
+ */
+ public function register_page(): void {
+ add_options_page(
+ esc_html__( 'Podcast RSS Import', 'rss2cpt' ),
+ esc_html__( 'Podcast RSS Import', 'rss2cpt' ),
+ 'manage_options',
+ self::PAGE_SLUG,
+ array( $this, 'render_page' )
+ );
+ }
+
+ /**
+ * Register the option, section, and fields.
+ *
+ * @return void
+ */
+ public function register_settings(): void {
+ register_setting(
+ 'rss2cpt_settings_group',
+ Options::KEY,
+ array(
+ 'type' => 'array',
+ 'default' => Options::get(),
+ 'sanitize_callback' => array( $this, 'sanitize_settings' ),
+ )
+ );
+
+ add_settings_section(
+ 'rss2cpt_source_section',
+ esc_html__( 'Import configuration', 'rss2cpt' ),
+ array( $this, 'render_section_description' ),
+ self::PAGE_SLUG
+ );
+
+ $fields = array(
+ 'feed_url' => __( 'Podcast feed URL', 'rss2cpt' ),
+ 'post_type' => __( 'Target post type', 'rss2cpt' ),
+ 'schedule' => __( 'Import schedule', 'rss2cpt' ),
+ 'post_status' => __( 'Imported post status', 'rss2cpt' ),
+ 'author_id' => __( 'Post author', 'rss2cpt' ),
+ 'import_image' => __( 'Episode images', 'rss2cpt' ),
+ 'update_existing' => __( 'Existing episodes', 'rss2cpt' ),
+ 'item_limit' => __( 'Per-run import limit', 'rss2cpt' ),
+ );
+
+ foreach ( $fields as $field => $label ) {
+ add_settings_field(
+ 'rss2cpt_' . $field,
+ esc_html( $label ),
+ array( $this, 'render_' . $field . '_field' ),
+ self::PAGE_SLUG,
+ 'rss2cpt_source_section'
+ );
+ }
+ }
+
+ /**
+ * Return normalized saved settings.
+ *
+ * @return array Settings values.
+ */
+ public function get_settings(): array {
+ return Options::get();
+ }
+
+ /**
+ * Sanitize settings before storage.
+ *
+ * @param mixed $input Submitted settings.
+ * @return array Sanitized settings.
+ */
+ public function sanitize_settings( $input ): array {
+ $current = $this->get_settings();
+ $input = is_array( $input ) ? $input : array();
+ $output = Options::get();
+
+ $feed_url = isset( $input['feed_url'] ) ? esc_url_raw( wp_unslash( $input['feed_url'] ), array( 'http', 'https' ) ) : '';
+ if ( '' !== $feed_url && ! wp_http_validate_url( $feed_url ) ) {
+ add_settings_error(
+ Options::KEY,
+ 'rss2cpt_invalid_feed_url',
+ esc_html__( 'Enter a valid HTTP or HTTPS podcast feed URL.', 'rss2cpt' )
+ );
+ $feed_url = (string) $current['feed_url'];
+ }
+ $output['feed_url'] = $feed_url;
+
+ $post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : '';
+ if ( ! $this->is_allowed_post_type( $post_type ) ) {
+ add_settings_error(
+ Options::KEY,
+ 'rss2cpt_invalid_post_type',
+ esc_html__( 'Select a post type that is available in the WordPress admin.', 'rss2cpt' )
+ );
+ $post_type = $this->is_allowed_post_type( (string) $current['post_type'] ) ? (string) $current['post_type'] : 'podcast_episode';
+ }
+ $output['post_type'] = $post_type;
+
+ $schedules = wp_get_schedules();
+ $schedule = isset( $input['schedule'] ) ? sanitize_key( $input['schedule'] ) : '';
+ if ( 'disabled' !== $schedule && ! isset( $schedules[ $schedule ] ) ) {
+ $schedule = ( 'disabled' === $current['schedule'] || isset( $schedules[ $current['schedule'] ] ) ) ? (string) $current['schedule'] : 'hourly';
+ }
+ $output['schedule'] = $schedule;
+
+ $allowed_statuses = array( 'draft', 'pending', 'private', 'publish' );
+ $post_status = isset( $input['post_status'] ) ? sanitize_key( $input['post_status'] ) : '';
+ $output['post_status'] = in_array( $post_status, $allowed_statuses, true ) ? $post_status : 'draft';
+
+ $author_id = isset( $input['author_id'] ) ? absint( $input['author_id'] ) : 0;
+ if ( 0 === $author_id ) {
+ $author_id = get_current_user_id();
+ }
+ if ( 0 !== $author_id && ! get_user_by( 'id', $author_id ) ) {
+ $author_id = 0;
+ }
+ $output['author_id'] = $author_id;
+
+ $output['import_image'] = empty( $input['import_image'] ) ? 0 : 1;
+ $output['update_existing'] = empty( $input['update_existing'] ) ? 0 : 1;
+ $output['item_limit'] = isset( $input['item_limit'] ) ? min( 500, absint( $input['item_limit'] ) ) : 0;
+
+ return $output;
+ }
+
+ /**
+ * Render the settings page.
+ *
+ * @return void
+ */
+ public function render_page(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+ ?>
+
+
+ render_last_import(); ?>
+
+
+
+
+
+
+
+ ' . esc_html( $message ) . '
';
+ }
+
+ /**
+ * Handle a verified manual import request.
+ *
+ * @return void
+ */
+ public function handle_manual_import(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die(
+ esc_html__( 'You are not allowed to run podcast imports.', 'rss2cpt' ),
+ esc_html__( 'Forbidden', 'rss2cpt' ),
+ array( 'response' => 403 )
+ );
+ }
+
+ check_admin_referer( self::NONCE_ACTION );
+
+ $result = array();
+
+ try {
+ $result = $this->importer->import( Options::get() );
+ } catch ( Throwable $exception ) {
+ // Expose the exception to opt-in logging without leaking details in the URL.
+ do_action( 'rss2cpt_manual_import_exception', $exception );
+ $this->scheduler->record_result( new \WP_Error( 'rss2cpt_import_exception', __( 'The importer stopped unexpectedly.', 'rss2cpt' ) ) );
+ $this->redirect_with_notice( 'error' );
+ }
+ $this->scheduler->record_result( $result );
+
+ if ( is_wp_error( $result ) ) {
+ $this->redirect_with_notice( 'error' );
+ }
+ if ( is_array( $result ) && ! empty( $result['has_more'] ) ) {
+ $this->scheduler->schedule_continuation();
+ }
+
+ $counts = $this->normalize_import_result( $result );
+ $this->redirect_with_notice( 'success', $counts );
+ }
+
+ /**
+ * Render a manual-import result notice.
+ *
+ * @return void
+ */
+ public function render_notices(): void {
+ if ( ! current_user_can( 'manage_options' ) || ! $this->is_settings_screen() ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $notice = isset( $_GET['rss2cpt_notice'] ) ? sanitize_key( wp_unslash( $_GET['rss2cpt_notice'] ) ) : '';
+ if ( '' === $notice ) {
+ return;
+ }
+
+ $class = 'notice notice-error';
+ $message = __( 'The podcast import could not be completed. Review the last import status above.', 'rss2cpt' );
+
+ if ( 'success' === $notice ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $imported = isset( $_GET['imported'] ) ? absint( $_GET['imported'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $updated = isset( $_GET['updated'] ) ? absint( $_GET['updated'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $skipped = isset( $_GET['skipped'] ) ? absint( $_GET['skipped'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $failed = isset( $_GET['failed'] ) ? absint( $_GET['failed'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $warnings = isset( $_GET['warnings'] ) ? absint( $_GET['warnings'] ) : 0;
+
+ $class = 0 < ( $failed + $warnings ) ? 'notice notice-warning' : 'notice notice-success';
+ $message = sprintf(
+ /* translators: 1: imported count, 2: updated count, 3: skipped count, 4: failed count, 5: warning count. */
+ __( 'Podcast import finished: %1$d imported, %2$d updated, %3$d skipped, %4$d failed, %5$d warnings.', 'rss2cpt' ),
+ $imported,
+ $updated,
+ $skipped,
+ $failed,
+ $warnings
+ );
+ }
+ ?>
+
+ ' . esc_html__( 'Choose the feed source and how episodes should be created. Scheduled changes take effect when the scheduler next synchronizes.', 'rss2cpt' ) . '