From 0438d250d4e4ef9e1b0d19c5240d7bac91922b9e Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Wed, 24 Jun 2026 07:49:09 +0200 Subject: [PATCH 1/2] chore: Add more options to the navigation system --- src/navigations/AccountNavigation.php | 64 ++++++++++---------- src/navigations/BaseNavigation.php | 2 +- src/navigations/Item.php | 4 +- src/navigations/ItemAction.php | 22 +++++++ src/navigations/ItemGroup.php | 3 +- src/navigations/ItemPlaceholder.php | 20 ++++++ src/navigations/ReadingNavigation.php | 32 +++++----- src/views/layouts/_sidenav_element.html.twig | 30 ++++++++- 8 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 src/navigations/ItemAction.php create mode 100644 src/navigations/ItemPlaceholder.php diff --git a/src/navigations/AccountNavigation.php b/src/navigations/AccountNavigation.php index 7c5ebc44d..0a0a9d759 100644 --- a/src/navigations/AccountNavigation.php +++ b/src/navigations/AccountNavigation.php @@ -25,65 +25,65 @@ public function elements(): array $security_items = []; $account_items[] = new Item( - 'account', - \Minz\Url::for('account'), - 'cog', - TwigExtension::translate('Overview'), + label: TwigExtension::translate('Overview'), + key: 'account', + url: \Minz\Url::for('account'), + icon: 'cog', ); if (!$current_user->isValidated()) { $account_items[] = new Item( - 'account validation', - \Minz\Url::for('account validation'), - 'check', - TwigExtension::translate('Account validation'), + label: TwigExtension::translate('Account validation'), + key: 'account validation', + url: \Minz\Url::for('account validation'), + icon: 'check', ); } if (!$current_user->isBlocked()) { $account_items[] = new Item( - 'mastodon', - \Minz\Url::for('mastodon'), - 'mastodon', - TwigExtension::translate('Mastodon'), + label: TwigExtension::translate('Mastodon'), + key: 'mastodon', + url: \Minz\Url::for('mastodon'), + icon: 'mastodon', ); } if (!$current_user->isBlocked()) { $data_items[] = new Item( - 'opml', - \Minz\Url::for('opml'), - 'upload', - TwigExtension::translate('OPML import'), + label: TwigExtension::translate('OPML import'), + key: 'opml', + url: \Minz\Url::for('opml'), + icon: 'upload', ); } $data_items[] = new Item( - 'exportation', - \Minz\Url::for('exportation'), - 'cloud-download', - TwigExtension::translate('Data export'), + label: TwigExtension::translate('Data export'), + key: 'exportation', + url: \Minz\Url::for('exportation'), + icon: 'cloud-download', ); $security_items[] = new Item( - 'security', - \Minz\Url::for('security'), - 'key', - TwigExtension::translate('Credentials'), + label: TwigExtension::translate('Credentials'), + key: 'security', + url: \Minz\Url::for('security'), + icon: 'key', ); $security_items[] = new Item( - 'sessions', - \Minz\Url::for('sessions'), - 'session', - TwigExtension::translate('Sessions'), + label: TwigExtension::translate('Sessions'), + key: 'sessions', + url: \Minz\Url::for('sessions'), + icon: 'session', ); $security_items[] = new Item( - 'account deletion', - \Minz\Url::for('account deletion'), - 'trash', - TwigExtension::translate('Account deletion'), + label: TwigExtension::translate('Account deletion'), + key: 'account deletion', + url: \Minz\Url::for('account deletion'), + icon: 'trash', ); return [ diff --git a/src/navigations/BaseNavigation.php b/src/navigations/BaseNavigation.php index da608d173..35c4a553c 100644 --- a/src/navigations/BaseNavigation.php +++ b/src/navigations/BaseNavigation.php @@ -27,7 +27,7 @@ public function currentLabel(): string } foreach ($items as $item) { - if ($this->isCurrent($item)) { + if ($item instanceof Item && $this->isCurrent($item)) { return $item->label; } } diff --git a/src/navigations/Item.php b/src/navigations/Item.php index 582dca2ac..a7f7a73c5 100644 --- a/src/navigations/Item.php +++ b/src/navigations/Item.php @@ -9,10 +9,10 @@ class Item extends Element { public function __construct( + public readonly string $label, public readonly string $key, public readonly string $url, - public readonly string $icon, - public readonly string $label, + public readonly string $icon = '', ) { } diff --git a/src/navigations/ItemAction.php b/src/navigations/ItemAction.php new file mode 100644 index 000000000..84e24fd68 --- /dev/null +++ b/src/navigations/ItemAction.php @@ -0,0 +1,22 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class ItemAction extends Element +{ + public function __construct( + public readonly string $label, + public readonly string $url, + public readonly string $icon, + ) { + } + + public function is(string $type): bool + { + return $type === 'action'; + } +} diff --git a/src/navigations/ItemGroup.php b/src/navigations/ItemGroup.php index a291bc085..883624b67 100644 --- a/src/navigations/ItemGroup.php +++ b/src/navigations/ItemGroup.php @@ -10,8 +10,9 @@ class ItemGroup extends Element { public function __construct( public readonly string $label, - /** @var Item[] */ + /** @var array */ public readonly array $items, + public readonly ?ItemAction $action = null, ) { } diff --git a/src/navigations/ItemPlaceholder.php b/src/navigations/ItemPlaceholder.php new file mode 100644 index 000000000..2297f311a --- /dev/null +++ b/src/navigations/ItemPlaceholder.php @@ -0,0 +1,20 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class ItemPlaceholder extends Element +{ + public function __construct( + public readonly string $label, + ) { + } + + public function is(string $type): bool + { + return $type === 'placeholder'; + } +} diff --git a/src/navigations/ReadingNavigation.php b/src/navigations/ReadingNavigation.php index 4ec820f5f..b4eab2810 100644 --- a/src/navigations/ReadingNavigation.php +++ b/src/navigations/ReadingNavigation.php @@ -22,33 +22,33 @@ public function elements(): array $elements = [ new Item( - 'news', - \Minz\Url::for('news'), - 'news', - TwigExtension::translate('News'), + label: TwigExtension::translate('News'), + key: 'news', + url: \Minz\Url::for('news'), + icon: 'news', ), new Item( - 'bookmarks', - \Minz\Url::for('bookmarks'), - 'bookmark', - TwigExtension::translate('To read'), + label: TwigExtension::translate('To read'), + key: 'bookmarks', + url: \Minz\Url::for('bookmarks'), + icon: 'bookmark', ), new Item( - 'read', - \Minz\Url::for('read list'), - 'check', - TwigExtension::translate('Links read'), + label: TwigExtension::translate('Links read'), + key: 'read', + url: \Minz\Url::for('read list'), + icon: 'check', ), ]; if ($current_user->isBetaEnabled()) { $elements[] = new Item( - 'explore', - \Minz\Url::for('explore'), - 'compass', - TwigExtension::translate('Explore'), + label: TwigExtension::translate('Explore'), + key: 'explore', + url: \Minz\Url::for('explore'), + icon: 'compass', ); } diff --git a/src/views/layouts/_sidenav_element.html.twig b/src/views/layouts/_sidenav_element.html.twig index 26f8ea3e4..197ace010 100644 --- a/src/views/layouts/_sidenav_element.html.twig +++ b/src/views/layouts/_sidenav_element.html.twig @@ -1,18 +1,44 @@ {% if element.is('group') %}
-

{{ element.label }}

+ {% if element.action %} +
+

{{ element.label }}

+ + +
+ {% else %} +

{{ element.label }}

+ {% endif %} {% for item in element.items %} {{ include('layouts/_sidenav_element.html.twig', { element: item }) }} {% endfor %}
+{% elseif element.is('placeholder') %} +

+ {{ element.label }} +

{% else %} - {{ icon(element.icon) }} + {% if element.icon %} + {{ icon(element.icon) }} + {% endif %} + {{ element.label }} {% endif %} From a255df50bde1b86f9079013b2bfa179e371d1506 Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Wed, 24 Jun 2026 07:19:42 +0200 Subject: [PATCH 2/2] feat(alpha): Allow to create and display streams --- locales/fr_FR/LC_MESSAGES/main.mo | Bin 58012 -> 58939 bytes locales/fr_FR/LC_MESSAGES/main.po | 166 +++++-- src/Router.php | 18 + src/assets/stylesheets/application.css | 1 + src/assets/stylesheets/components/sources.css | 56 +++ src/auth/Access.php | 2 + src/auth/StreamsAccess.php | 27 ++ src/controllers/Streams.php | 94 ++++ src/controllers/streams/Sources.php | 138 ++++++ src/forms/streams/AddSource.php | 13 + src/forms/streams/RemoveSource.php | 13 + src/forms/streams/Stream.php | 26 ++ .../Migration202606240001CreateStreams.php | 59 +++ src/models/FollowedCollection.php | 10 + src/models/Stream.php | 157 +++++++ src/models/StreamToFollow.php | 68 +++ src/models/StreamView.php | 40 ++ src/models/User.php | 26 ++ src/models/dao/Collection.php | 28 ++ src/models/dao/Link.php | 83 ++++ src/navigations/ReadingNavigation.php | 30 ++ src/schema.sql | 25 ++ src/views/streams/_form.html.twig | 80 ++++ src/views/streams/new.html.twig | 11 + src/views/streams/show.html.twig | 123 ++++++ src/views/streams/sources/_source.html.twig | 59 +++ src/views/streams/sources/edit.html.twig | 72 ++++ tests/controllers/StreamsTest.php | 196 +++++++++ tests/controllers/streams/SourcesTest.php | 405 ++++++++++++++++++ tests/factories/StreamFactory.php | 47 ++ tests/models/StreamViewTest.php | 333 ++++++++++++++ 31 files changed, 2359 insertions(+), 47 deletions(-) create mode 100644 src/assets/stylesheets/components/sources.css create mode 100644 src/auth/StreamsAccess.php create mode 100644 src/controllers/Streams.php create mode 100644 src/controllers/streams/Sources.php create mode 100644 src/forms/streams/AddSource.php create mode 100644 src/forms/streams/RemoveSource.php create mode 100644 src/forms/streams/Stream.php create mode 100644 src/migrations/Migration202606240001CreateStreams.php create mode 100644 src/models/Stream.php create mode 100644 src/models/StreamToFollow.php create mode 100644 src/models/StreamView.php create mode 100644 src/views/streams/_form.html.twig create mode 100644 src/views/streams/new.html.twig create mode 100644 src/views/streams/show.html.twig create mode 100644 src/views/streams/sources/_source.html.twig create mode 100644 src/views/streams/sources/edit.html.twig create mode 100644 tests/controllers/StreamsTest.php create mode 100644 tests/controllers/streams/SourcesTest.php create mode 100644 tests/factories/StreamFactory.php create mode 100644 tests/models/StreamViewTest.php diff --git a/locales/fr_FR/LC_MESSAGES/main.mo b/locales/fr_FR/LC_MESSAGES/main.mo index 5b19d971d96c04b0015ee1a8cab225706a52a2ae..7a70c580343a3a99aa3444a8855a176479b5e801 100644 GIT binary patch delta 13482 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⁡Lr%Q{;aV?@`v_WpG@ynL+tO;sblmhw^u}T)r6Vzl)0L#3QzHKIN)h zs5j#{b$_Fdu?FWdbuZhtm1y^gEswzyL=Z9D*4ay((zFXAQmC(w{~Vte<1V|&u5;oG zVluIxSYlh0r*W1oN7)Pi=KNR09_mLEbM5&|>U8wQuEbWGKZlLEwz|E(FKzY_JEM#kFt)Zh(y}-Bu}EeQ8H1$-kr=ZSzjFDPw!>XX|M4?=hZI z56<7ukylf*3I6I-{5ax@oR#HK_Wq6X&*F@X9w zJV*>6BFXCz;hgV->DZR~3m8nyB6PIS`oBb>iZaUo|L8`=3ZfO~y3@h;l+WQ@!rL}( zPx%Dphi%@Owp}PMq`o5MWw!ov>`i`_`pJ}EBzpN!F@p*n>#?>e7XAE|Jd)D=2kLSt ze@J~JTesX^dz`!xoh-+>3c7-^p8@W5+fA=#-}>AL#A z_JymnD*FC2UeD^R1ED@iuKMv!6B0eCQ}T=dui5gPoHo^`u-m-nk|w%x3Ye?>jNJ5z zuC(;Le8GCoFD|K{EL4JB};nwY!ygFuN=6Rmk c5g!qd?#j-|a+%hJTaQ;SX?ov!{6@L|1IOx)y#N3J delta 12690 zcmZA733yM({>Sl?9YI1O5eZ3%2!aS=Ct^+Q`x^UNOKmM$`crEuQ6lKo+G^kVsjAk} ziWa4oR&7d@BawJ~hD8*6XMzd5CK7 zTf^i9QSGBqGf>T%jQr1OZu4mvOg<0u<7#xNu!Dl0Y(MH<-$!-u1T}Kcnr6iQsE!IF zOT>xAidYUiVpr5!*oLwA1FHQ~_@(&7W2P=!oO6E!+FemZ}h9i^u->~(wQ8Tg}b)$6{f?H6FGYgaPN9=&1j8?B|80rs_sBM;vda{0~CmDvCkuk_*I^$8-yHT(7Yt#&#LJi~>)C0So zQ_x6)8ki2sp&Hgh%|r*(h)1FZHqkl_)!`iLd{nzdr~$1)J^2RI!1rQl{0?LB5k}L$ z6P{uk)J2V`EtbK7sDY$gS0g*g*^BBp8}*L=LhTywhUWSpREKe>S5*f!pcK>tq~Zc> zi}kesZ`c#18ky}=9@SBj&4;5d9FLmP$*7JqP#vtq7Pt;O<0DMK7LCoKorD^|KGck4 zVFaGV{PgcUprEOHfnn(1#5638dId?SCu)c4cpwhOvFMAJQSbN$Y9{_bZ+wO^=-JeK zpvs_Lb$4VVIiu0li^4Vv^z8&RV*z1DWKx`?s16IHns*nD`ldHPE!JUJ5@%x{+=6*9 zfX}D~P!yYEaSXsA*cvCG+8up``H!P;hKfjhYEOi7l@ECgvQC_GsC_*Q{cyTHKNs}@ z%Rud-6}EmSYR2}UKOVs6eX$a%gCx`d>Y+MrgnD;rm>)-= zZuAzaqs6Ev+Ju_oU8wtgi@N_A%!`*%GkOcv{!f=Z@f^LV$kWoi!~CcQ;no<`lvlv~ z*c>&*U94%S4*H^|dKhXgOtkgyp{~zF4a|-D4!HJG&<)REH@t$2u~sWH6K7E8FQTUM zmh~alCx2n{`n*_A^5&=qX@!-s9meA{EPy*~{Q+bkE+?CUp7b#mM2|Km4@B+%FigZs z_%8NEeovg=Py;L7*0fK=K=RsH1zVs7JlUR~gBn04>J@*C0ownYD5#^)QSb6J>c%%w z7d$`>%xPy9SpaJ4qfs5iqOMQ0HbcGho~Zi`L4DXJp*miGy8nML=l6e|t=NNwIdK?u z!}F*M|FHF5?ai-U2o|Hh8tPrP!2s-xnt?$WgyT{7nS*M#3f1uj)IfHlODFbI&^|tl zdKI^=*%(Cr1T_N%JD33#$3oIe?OLKb?1S1p<50V25~}?o>w467-`j!t*N87t zp$>jSjqnNT3H>@UO;{3l<38kVIAc4RSMW8mm7Q-spL18IvP*cthS zc1B*)hZ;aoFY{-^ zc+_=0Fdj#vu3wH7aW@vjn;3>pZ_~aQRw7M6{r)(;Dd@(VFd4H@Gvd?7{7g2*y5!SP z9eicIfZW@;kNnSR(>Lc6;p{{Wz^9+NzA9=ljzSH11}5T%7)JljNeT)0D{3l>^=DeK zHIB#es3*wAWb_|k|A2v|$R}YlT#1^AJ6Ie;2bxcCHGGGB5Hg9*uNZ;N1~LB{=>Q7a zMhmefUPWz>VuQ_UZ-yG!NYs?BK|ef*Ij;!olb=J)MCcInU6_FSFio-f0#y5@wtnLf z=HHi!y;Q{Dm#EeH3+BNms44S$)pQt!8b~y@$4;n@H==gQ9n>~{in`u!sCjjP7)9O+ zOWFS>Hq87W{|k*Xf2d4Ez3VhAjAO6>&cOV* z5W{gLcEx>|7em=1g)kbGS3zA@7b|0P)PqezzFCeN*=SDS1m(Q{u@u@;aT@vfIi=p@ zk5%Zx6g-b%82*;A8U~WL#-i98Yv2To#GM#`XROyz9Y4T)*m|N_%-t{#{X2ar=z^iB z-S8IbiKn2ZbRK%)$Ea1k4z&%pV+ekW+K$&z*WW>1pN*Q4=QtDdOyV!J_&&}-zqfhF z4ipwrIEcTZ4{l^s>S#OaNk79-Jce4N*Rde_Pc|JC!vyje)RcF`_Ba!@b}pgbxyL)) zi?tDp{^VDuF#j6)?^Dd`euP>ig{B&-qB`u1C2%rU#pM`*KOh^$$;Kwwdzz_Vhds!% zP!AA2-OO+zYDQ8p7+Xx|Ni>o^RA}mkqZZ*<)P9|fI=>$E1iNhg1=Oqg+t&NfFs~p1 zTT)*GQ*j2i!Bbclqj?YYu@}z9qE>Yjs=g-bhOMkUQ3D%+ zn%d2%-LcP}KWDv#dR2d;wqraiE+5uHe{5`Wm(!Ml7FQ4S#<5rlC*T0wfMqaXuK5&K zM@@YnR0ppkp8{tivcsH`>HL)AG8~1k%;S&X_yuZl#=dW^>w+oT|8ps5>P}hDVKn*A zs5RisH&0js3y{{pJlF(vU2Dw3PUwf>d^RGQ~`IO z-q9Y^J35KlMmKQ~*32*iT8X;B0h^ygP3fPg4m=i`cOHSI$Sa~&eLD=s9;n?j3SAm$ zI)yk~hiZ5VHK2>uYuJ+fHfn%LnP&gDM0MB$wMfUI?zb4#ZaWsiZ!j1yquM>d{8)Gq z^RKClTx6y$4%Kl2s=hkvhRLXbb;Tg;haor)HMQv&k6Tb{Z`4APyQ0kLW1MGqtP%qS~9)de@EcV1QADH{iK)srI zSO=G)cGG#(TDk4A75AyCCoAhyCH*bDokwp|vsK+mOSAg!?wc_-9N3`Q-|nW!nx z#4y~5dVnk}hd*Hyy8J#gQxu2OsA!0K!Xv1WpTvTA$=2V&isac?9iu)nQ`!Z)laIBY z!v5s(%N%DG&c{>?Uv6IE04z=a&IAhD@5@o&{*xGs50Gz@6Sl%k)oRqtY{q`L1NB5j zR+=fVfSS@dSQZze?zbQHK-a9dQ8RcCL$v?vtTGq2M;}gf$D-H=HHB~5`gy1hGf^|M z1@q!(7=?$hJl;UPg20c>`4Xsi9*6m{2I@YIu?YP;X%w{R#$Xgq!{WFGwI~mv26PbyZs)H-YJ9K_WO>yBhX6nOGi>@K|#x$gfvmZ5M{nnZ_HGD1euNzIILe9ceT!fmD ztLTZ@=!1_i5uacctgy~ZZ68b`ABB2=_15jE0q;TG{}@K#Ijn<^)-nGYS>^TSuTVdt z8b)p~BaKIW!ICfmTcBoS5^BodLv^qieQ^WoU2jLtz(Lf^UPRsZ9_spkQTNT~+GuVN zh2B&o+PpeyKc}J^4n}Rmw^1`Q7qupqp&x#V>fivX{c+R)ucHR?hpo@E$^7ZM1nPdS z$`mvu$yf%{P>XIls-tw&yI+pc_$d~{3z!!lp%&|N)IcIOo5dM}<;W}9yqk3Zs=txQ zeO=C63UO4dL5=)7)BtXvrtU842|Tx$`Uup_lt#@!BMiYbEQMoH_gR8k^;=P|=quC% z{D8;tFFc|Be`qVCp<@0v(}Cx9^KzjIGgom-#p04%m)-8|q0tKQRMJ z#hmX4mZp9R#^QQxgeS2p2Jbfc2-Nj%Y>7{HGymZfntf`29@k(qJcIQx{Gj=#oo@IZ`9ds)uBb2h z3n+!!SRa>R8T=X5k?&XL9}e21_H#PwNk79y_yA|%tV3pX2WFWsWd-a^eNRln-B<_j zVr?vUIAIchqH#cAY? zu`C|L=J*IpV)8Nb=YxJ&k$fJ;(Z92wf;zZ`dUt~qqr);g#enu)sM zF>H)au>&?ZWuABu)+9fO&G88~#U`iC45T9)$Jv2eYs1d4|FuXqpW*$X_xI)l;`xIa zaaq(V?}~4F@b7l$CcksmbhPY8vn$S{p3LK%`3}TmRq}4A-8Bz2V;e9(9>fB8;vDm@ z#c_#>(f9=O;_&lkdyK<;+pX8J6M66jo(6~FAUuzQG5I22Mcja6F#eL6 zkrfz6e%nPMoI;_WOa~P(o;(>f<-<{nXFCSr_ZWz`P#r!-J<)*6X0eX8zJ+Qx4gD|^ z)$SuKh8r;tx(-v&6Mlyp$OYTrPt=8hKbwX{Q3HrZO9x!BF~l%2Ci%q@ecqQ1r$(u@bs43AbTK`~~$2>RdPPdIWlq>xd$T zlH?=0Q|9eB-xI^h^AfD8=R_7!kdqBj$1Tc#;P>`| zso0a8&60CW;=colc6RjM0LvBh*t<58>rK{ zsN@5dbKK^?^*DLW=IkOThK5hjgZg7c?%_>+8hPSNb+5lvUP!}C^5sMcdr?)c*<#y_ z&yn}9{o0rlBdAzLv?m`l3B+-SOEuC|`Z56*${}G{M5f-)si6(zW)TizZT#fqjEx;?N zgbwZRUkQKmpNUT>d)jlCC|@Qzl55{b*nv>Y z`86zs+c~#i6&$yS#YA4(MN>bM^4}Ot-0@%xMJOavnMu4&c?$*;y(zy+=y+nCf!j@$ z^Bna(NT;s5J%5h83LWhuzeJoNwi2s|Aw=%ckT&|gFmnKLRBXf_RYk*aV%JL-Q0;gT zZK+EkcB{f3zFhk+vk1fgw{9iVYDZiyU&X=cLk@8J@(L2^!)Qu(n&Yk}l5*;OQ zsVO>bC>Nppt35Z2awB_gFLhUl8-)I#p(BKdB|nO<;C-x3OriW6KEiVLA!_4I@&JAR zbyOyKNYp0YBXSRa8jiMkHOe*ZiQCkjwdGpqM>HdrymakHwUQ64wcb`Wldm zuDaada|%IJY$BFYu18%Mp`!+l!Z0FM6&(KPhkiC)!~)p`)Z#<(iZy*nASk6K9D);w$0-Z5Co3+u?f3I$9I&5le`Q zoSTT#)ZU)YS${*Q)SvAp5HmT^6n`N?D32uGAli~oMjiDGP62Lsn(}d5-@>{Q#}Pi9 zi=@rZ*ou45LIZY<3B`M%JYaGwl0;r zT|^n88j*8w{SJFRx2mEoJhiUPJz*W>!EfPlk`OK~M9ijqhxmdU{h2eoJk-`Dn@jB{ z_VzK9zolFj=MjI|`e~H=Qoc%rQ;xtksADWqiF^J3agd4v_WCH!&!s$oxJdni+^Pp%A^7s?chp0+Cv~6PTpuEWgY}uZ2 zih1&N7)%z-iT`p@b*zJL5>u!jjJZc8Th@ZQZp-;;{~G0!H2M=~5x(|ZLF&3vE<&4v zL?QChw9nay?PHlGyZ9CF5`Ymh`VUR z`}y3VtpYvViLIylxWDKS?~@VU?ST7ww;RE3|B+jQ{1T%QYSgNdn^ARkyD2&G7!@ Qiu?8_mBQRTPF@N5AAxMZ;{X5v diff --git a/locales/fr_FR/LC_MESSAGES/main.po b/locales/fr_FR/LC_MESSAGES/main.po index 2b650b05c..ed0b53860 100644 --- a/locales/fr_FR/LC_MESSAGES/main.po +++ b/locales/fr_FR/LC_MESSAGES/main.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Flus\n" -"POT-Creation-Date: 2026-06-22 08:36+0200\n" -"PO-Revision-Date: 2026-06-22 08:36+0200\n" +"POT-Creation-Date: 2026-06-26 10:35+0200\n" +"PO-Revision-Date: 2026-06-26 10:35+0200\n" "Last-Translator: Marien Fressinaud \n" "Language-Team: \n" "Language: fr_FR\n" @@ -320,21 +320,21 @@ msgstr "[%s] Réinitialisation de votre mot de passe" msgid "[%s] Your account will be deleted soon due to inactivity" msgstr "[%s] Votre compte sera bientôt supprimé pour cause d'inactivité" -#: models/Collection.php:41 models/Group.php:30 +#: models/Collection.php:41 models/Group.php:30 models/Stream.php:32 msgid "The name is required." msgstr "Le nom est obligatoire." -#: models/Collection.php:45 models/Group.php:34 +#: models/Collection.php:45 models/Group.php:34 models/Stream.php:36 msgid "The name must be less than {max} characters." msgstr "Le nom ne doit pas faire plus de {max} caractères." #: models/Collection.php:139 models/Collection.php:226 -#: navigations/ReadingNavigation.php:35 +#: models/ReadLaterSource.php:20 navigations/ReadingNavigation.php:32 msgid "To read" msgstr "À lire" -#: models/Collection.php:150 models/Collection.php:228 -#: navigations/ReadingNavigation.php:42 +#: models/Collection.php:150 models/Collection.php:228 models/ReadSource.php:20 +#: navigations/ReadingNavigation.php:39 msgid "Links read" msgstr "Liens lus" @@ -343,7 +343,7 @@ msgid "Links never to read" msgstr "Liens à ne jamais lire" #: models/Collection.php:172 models/Collection.php:230 -#: navigations/ReadingNavigation.php:28 +#: navigations/ReadingNavigation.php:25 msgid "News" msgstr "Journal" @@ -375,6 +375,14 @@ msgstr "L’origine ne doit pas faire plus de {max} caractères." msgid "The content is required." msgstr "Le contenu est obligatoire." +#: models/ReadLaterSource.php:25 +msgid "Place here the links you want to consult later on." +msgstr "Placez ici les liens que vous souhaitez consulter plus tard." + +#: models/ReadSource.php:25 +msgid "Find here all the links you’ve marked as read." +msgstr "Retrouvez ici tous les liens que vous avez marqués comme lu." + #: models/Topic.php:31 msgid "The label is required." msgstr "Le nom est obligatoire." @@ -441,35 +449,35 @@ msgstr "" msgid "Account & data menu" msgstr "Menu compte & données" -#: navigations/AccountNavigation.php:31 +#: navigations/AccountNavigation.php:28 msgid "Overview" msgstr "Aperçu" -#: navigations/AccountNavigation.php:39 +#: navigations/AccountNavigation.php:36 msgid "Account validation" msgstr "Validation du compte" -#: navigations/AccountNavigation.php:48 +#: navigations/AccountNavigation.php:45 msgid "Mastodon" msgstr "Mastodon" -#: navigations/AccountNavigation.php:57 +#: navigations/AccountNavigation.php:54 msgid "OPML import" msgstr "Importation OPML" -#: navigations/AccountNavigation.php:65 +#: navigations/AccountNavigation.php:62 msgid "Data export" msgstr "Exportation des données" -#: navigations/AccountNavigation.php:72 +#: navigations/AccountNavigation.php:69 msgid "Credentials" msgstr "Identifiants" -#: navigations/AccountNavigation.php:79 +#: navigations/AccountNavigation.php:76 msgid "Sessions" msgstr "Sessions" -#: navigations/AccountNavigation.php:86 +#: navigations/AccountNavigation.php:83 msgid "Account deletion" msgstr "Suppression du compte" @@ -489,10 +497,22 @@ msgstr "Sécurité" msgid "Reading menu" msgstr "Menu lecture" -#: navigations/ReadingNavigation.php:51 +#: navigations/ReadingNavigation.php:48 msgid "Explore" msgstr "Explorer" +#: navigations/ReadingNavigation.php:57 +msgid "New stream" +msgstr "Nouveau fil" + +#: navigations/ReadingNavigation.php:74 +msgid "Create a stream to get started." +msgstr "Créez un fil pour commencer." + +#: navigations/ReadingNavigation.php:79 +msgid "Streams" +msgstr "Fils" + #: seeds.php:12 msgid "Business" msgstr "Business" @@ -604,10 +624,6 @@ msgstr "Info" msgid "Success" msgstr "Succès" -#: views/bookmarks/index.html.twig:30 -msgid "Place here the links you want to consult later on." -msgstr "Placez ici les liens que vous souhaitez consulter plus tard." - #: views/bookmarks/index.html.twig:45 views/collections/show.html.twig:309 msgid "Add a link" msgstr "Ajouter un lien" @@ -637,7 +653,7 @@ msgstr "aucun lien" #: views/collections/_collection.html.twig:26 #: views/collections/_collection_follow.html.twig:18 #: views/links/_pagination_count.html.twig:7 views/news/index.html.twig:26 -#: views/news/index.html.twig:132 +#: views/news/index.html.twig:132 views/streams/show.html.twig:101 #, php-format msgid "%s link" msgid_plural "%s links" @@ -657,7 +673,9 @@ msgstr "privée" #: views/collections/_collection.html.twig:52 #: views/collections/_collection_follow.html.twig:24 -#: views/collections/show.html.twig:96 +#: views/collections/show.html.twig:96 views/streams/show.html.twig:34 +#: views/streams/sources/_source.html.twig:34 +#: views/streams/sources/edit.html.twig:48 msgid "Publication frequency" msgstr "Fréquence de publication" @@ -690,7 +708,7 @@ msgstr "Comment voulez-vous nommer cette collection ?" #: views/collections/_form.html.twig:14 #: views/collections/_selector.html.twig:48 #: views/collections/groups/edit.html.twig:23 views/groups/edit.html.twig:14 -#: views/mastodon/show.html.twig:56 +#: views/mastodon/show.html.twig:56 views/streams/_form.html.twig:14 #, php-format msgid "(max. %d characters)" msgstr "(max. %d caractères)" @@ -723,10 +741,11 @@ msgstr "(max. %d caractères)" #: views/my/security/confirmation.html.twig:40 #: views/my/security/show.html.twig:31 views/my/security/show.html.twig:59 #: views/notes/edit.html.twig:24 views/passwords/edit.html.twig:31 -#: views/passwords/forgot.html.twig:43 views/registrations/new.html.twig:51 -#: views/registrations/new.html.twig:79 views/registrations/new.html.twig:113 -#: views/registrations/new.html.twig:151 views/sessions/new.html.twig:32 -#: views/sessions/new.html.twig:67 views/support/show.html.twig:41 +#: views/passwords/forgot.html.twig:43 views/registrations/new.html.twig:52 +#: views/registrations/new.html.twig:80 views/registrations/new.html.twig:114 +#: views/registrations/new.html.twig:152 views/sessions/new.html.twig:32 +#: views/sessions/new.html.twig:67 views/streams/_form.html.twig:20 +#: views/streams/_form.html.twig:51 views/support/show.html.twig:41 #: views/support/show.html.twig:67 msgid "Error:" msgstr "Erreur :" @@ -737,11 +756,11 @@ msgstr "Quelle est la raison d’être de cette collection ?" #: views/collections/_form.html.twig:45 #: views/links/collections/_new_note_form.html.twig:5 -#: views/my/security/show.html.twig:53 +#: views/my/security/show.html.twig:53 views/streams/_form.html.twig:45 msgid "(optional)" msgstr "(optionnel)" -#: views/collections/_form.html.twig:69 +#: views/collections/_form.html.twig:69 views/streams/_form.html.twig:69 msgid "You can format the description in Markdown." msgstr "Vous pouvez formater la description en Markdown." @@ -1003,6 +1022,7 @@ msgid "Change the illustration" msgstr "Changer l’illustration" #: views/collections/show.html.twig:88 +#: views/streams/sources/_source.html.twig:18 #, php-format msgid "Feed of %s website" msgstr "Flux du site %s" @@ -2015,14 +2035,14 @@ msgstr "" "Vous êtes sur le point de supprimer votre compte : vos données seront alors " "perdues. Cette action ne peut pas être annulée !" -#: views/my/account/deletion.html.twig:33 views/registrations/new.html.twig:105 +#: views/my/account/deletion.html.twig:33 views/registrations/new.html.twig:106 msgid "Your password" msgstr "Votre mot de passe" #: views/my/account/deletion.html.twig:63 #: views/my/security/confirmation.html.twig:65 #: views/my/security/show.html.twig:84 views/passwords/edit.html.twig:56 -#: views/registrations/new.html.twig:139 views/sessions/new.html.twig:95 +#: views/registrations/new.html.twig:140 views/sessions/new.html.twig:95 msgid "" "Show password as plain text. Note: this will visually expose your password." msgstr "" @@ -2132,7 +2152,7 @@ msgstr "" msgid "Your name" msgstr "Votre nom" -#: views/my/profile/edit.html.twig:14 views/registrations/new.html.twig:45 +#: views/my/profile/edit.html.twig:14 views/registrations/new.html.twig:46 #, php-format msgid "(public, max. %d characters)" msgstr "(public, max. %d caractères)" @@ -2174,7 +2194,7 @@ msgstr "Confirmer votre mot de passe" msgid "You can change your login details here." msgstr "Vous pouvez changer vos identifiants de connexion ici." -#: views/my/security/show.html.twig:26 views/registrations/new.html.twig:74 +#: views/my/security/show.html.twig:26 views/registrations/new.html.twig:75 msgid "Your email address" msgstr "Votre adresse courriel" @@ -2715,7 +2735,7 @@ msgstr "Vous êtes en train de modifier le mot de passe de %s." msgid "Your new password" msgstr "Votre nouveau mot de passe" -#: views/passwords/edit.html.twig:25 views/registrations/new.html.twig:107 +#: views/passwords/edit.html.twig:25 views/registrations/new.html.twig:108 msgid "(recommended at least 8 characters)" msgstr "(recommandé au moins 8 caractères)" @@ -2820,33 +2840,29 @@ msgstr "Vous n’avez encore partagé aucun lien." msgid "This user didn’t share any links yet." msgstr "Cet·te utilisateur·ice n’a pas encore partagé de lien." -#: views/read/index.html.twig:30 -msgid "Find here all the links you’ve marked as read." -msgstr "Retrouvez ici tous les liens que vous avez marqués comme lu." - #: views/registrations/new.html.twig:3 views/registrations/new.html.twig:11 -#: views/registrations/new.html.twig:23 +#: views/registrations/new.html.twig:24 msgid "Registration" msgstr "Inscription" -#: views/registrations/new.html.twig:27 +#: views/registrations/new.html.twig:28 #, php-format msgid "First month is free. Learn more about pricing." msgstr "" "Le premier mois est gratuit. En savoir plus sur les tarifs." -#: views/registrations/new.html.twig:43 +#: views/registrations/new.html.twig:44 msgid "What’s your name?" msgstr "Quel est votre nom ?" -#: views/registrations/new.html.twig:99 +#: views/registrations/new.html.twig:100 msgid "We only send emails necessary for the operation of the service." msgstr "" "Nous n’y enverrons que des courriels nécessaires au fonctionnement du " "service." -#: views/registrations/new.html.twig:157 +#: views/registrations/new.html.twig:158 #, php-format msgid "" "Please read the general terms and " @@ -2856,7 +2872,7 @@ msgstr "" "générales de services et d’utilisation pour vous assurer que celles-ci " "vous conviennent." -#: views/registrations/new.html.twig:173 +#: views/registrations/new.html.twig:174 msgid "" "I acknowledge that I have read and accepted the general terms and conditions " "of service and use" @@ -2864,11 +2880,11 @@ msgstr "" "Je reconnais avoir lu et accepté les conditions générales de services et " "d’utilisation" -#: views/registrations/new.html.twig:185 +#: views/registrations/new.html.twig:186 msgid "Sign up" msgstr "S’inscrire" -#: views/registrations/new.html.twig:193 +#: views/registrations/new.html.twig:194 msgid "Already an account?" msgstr "Déjà un compte ?" @@ -2889,6 +2905,62 @@ msgstr "Mot de passe oublié ?" msgid "No account yet?" msgstr "Pas encore de compte ?" +#: views/streams/_form.html.twig:12 +msgid "What’s the name of the stream?" +msgstr "Quelle est le nom du fil ?" + +#: views/streams/_form.html.twig:43 +msgid "What’s the purpose of the stream?" +msgstr "Quelle est la raison d’être du fil ?" + +#: views/streams/new.html.twig:9 +msgid "Create the stream" +msgstr "Créer le fil" + +#: views/streams/show.html.twig:41 views/streams/sources/_source.html.twig:22 +msgid "By" +msgstr "Par" + +#: views/streams/show.html.twig:59 +msgid "Manage sources" +msgstr "Gérer les sources" + +#: views/streams/sources/_source.html.twig:45 +msgid "Add" +msgstr "Ajouter" + +#: views/streams/sources/_source.html.twig:53 +msgid "Remove" +msgstr "Retirer" + +#: views/streams/sources/edit.html.twig:3 +#, php-format +msgid "Sources of %s" +msgstr "Sources de %s" + +#: views/streams/sources/edit.html.twig:20 +msgid "Add or remove sources to customise your stream." +msgstr "Ajoutez ou retirez des sources pour personnaliser votre fil." + +#: views/streams/sources/edit.html.twig:27 +msgid "Sources to add" +msgstr "Sources à ajouter" + +#: views/streams/sources/edit.html.twig:41 +msgid "Sources of the stream" +msgstr "Sources du fil" + +#: views/streams/sources/edit.html.twig:45 +#, php-format +msgid "%d source" +msgid_plural "%d sources" +msgstr[0] "%d source" +msgstr[1] "%d sources" + +#: views/streams/sources/edit.html.twig:67 +msgid "Continue to the stream" +msgstr "Continuer vers le fil" + #: views/support/show.html.twig:18 msgid "" "If you have a question, want to make suggestions, or if you’re facing an " diff --git a/src/Router.php b/src/Router.php index baebd3dbb..24db9c9a1 100644 --- a/src/Router.php +++ b/src/Router.php @@ -214,6 +214,24 @@ public static function load(): \Minz\Router $router->addRoute('GET', '/feeds.xsl', 'Feeds#xsl', 'feeds xsl'); + // Streams + $router->addRoute('GET', '/streams/new', 'Streams#new', 'new stream'); + $router->addRoute('POST', '/streams/new', 'Streams#create', 'create stream'); + $router->addRoute('GET', '/streams/:id', 'Streams#show', 'stream'); + $router->addRoute('GET', '/streams/:id/sources/edit', 'streams/Sources#edit', 'edit stream sources'); + $router->addRoute( + 'POST', + '/streams/:id/sources/:source_id/add', + 'streams/Sources#add', + 'add stream source', + ); + $router->addRoute( + 'POST', + '/streams/:id/sources/:source_id/remove', + 'streams/Sources#remove', + 'remove stream source', + ); + // Explore $router->addRoute('GET', '/explore', 'Explore#show', 'explore'); $router->addRoute('GET', '/discovery', 'Explore#discovery', 'discovery'); diff --git a/src/assets/stylesheets/application.css b/src/assets/stylesheets/application.css index 1dbbe9668..8e4d66804 100644 --- a/src/assets/stylesheets/application.css +++ b/src/assets/stylesheets/application.css @@ -12,6 +12,7 @@ @import url('./components/links.css'); @import url('./components/notepads.css'); @import url('./components/shares.css'); +@import url('./components/sources.css'); @import url('./components/threads.css'); @import url('./components/topics.css'); @import url('./components/turbo.css'); diff --git a/src/assets/stylesheets/components/sources.css b/src/assets/stylesheets/components/sources.css new file mode 100644 index 000000000..0dda00b27 --- /dev/null +++ b/src/assets/stylesheets/components/sources.css @@ -0,0 +1,56 @@ +.sources { + position: relative; +} + +.sources__selector { + position: relative; +} + +.sources__selector-header { + position: sticky; + top: 0; + + padding-top: var(--space-smaller); + padding-bottom: var(--space-smaller); + + background-color: var(--color-background); +} + +.sources__submit { + position: sticky; + right: 0; + bottom: calc(var(--layout-header-height) + var(--space-smaller)); + left: 0; + + max-width: var(--width-large); + margin-right: auto; + margin-left: auto; + padding: var(--space-medium); + + text-align: right; + + background-color: var(--color-accent-bg-subtle); + border-radius: var(--border-radius); +} + +@media (min-width: 800px) { + .sources__submit { + bottom: var(--space-smaller); + } +} + +.source { + padding: var(--space-medium); + + background-color: var(--color-base); + border: 1px solid var(--color-grey-line); + border-radius: var(--border-radius); +} + +.source__image { + width: 64px; + max-width: none; + + background-color: var(--color-base); + border-radius: var(--border-radius); +} diff --git a/src/auth/Access.php b/src/auth/Access.php index 98d881f85..d1bc9da35 100644 --- a/src/auth/Access.php +++ b/src/auth/Access.php @@ -39,6 +39,8 @@ public static function can(?models\User $user, string $action, object $subject): $access_class = ImportationsAccess::class; } elseif ($subject instanceof models\Session) { $access_class = SessionsAccess::class; + } elseif ($subject instanceof models\Stream) { + $access_class = StreamsAccess::class; } else { throw new \InvalidArgumentException("{$subject_class} subject is not supported"); } diff --git a/src/auth/StreamsAccess.php b/src/auth/StreamsAccess.php new file mode 100644 index 000000000..4f1cfe2b8 --- /dev/null +++ b/src/auth/StreamsAccess.php @@ -0,0 +1,27 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class StreamsAccess +{ + public static function canView(?models\User $user, models\Stream $stream): bool + { + return $user && $user->id === $stream->user_id; + } + + public static function canUpdate(?models\User $user, models\Stream $stream): bool + { + return $user && $user->id === $stream->user_id; + } + + public static function canDelete(?models\User $user, models\Stream $stream): bool + { + return $user && $user->id === $stream->user_id; + } +} diff --git a/src/controllers/Streams.php b/src/controllers/Streams.php new file mode 100644 index 000000000..6910650a1 --- /dev/null +++ b/src/controllers/Streams.php @@ -0,0 +1,94 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class Streams extends BaseController +{ + /** + * @response 200 + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + */ + public function new(): Response + { + auth\CurrentUser::require(); + + $form = new forms\streams\Stream(); + + return Response::ok('streams/new.html.twig', [ + 'form' => $form, + ]); + } + + /** + * @request_param string name + * @request_param string description + * @request_param string csrf_token + * + * @response 400 + * If at least one of the parameters is invalid. + * @response 302 /streams/:id/sources/edit + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + */ + public function create(Request $request): Response + { + $user = auth\CurrentUser::require(); + + $stream = $user->initStream(); + $form = new forms\streams\Stream(model: $stream); + + $form->handleRequest($request); + + if (!$form->validate()) { + return Response::badRequest('streams/new.html.twig', [ + 'form' => $form, + ]); + } + + $stream = $form->model(); + $stream->save(); + + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + /** + * @request_param string id + * + * @response 200 + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream doesn't exist. + * @throws auth\AccessDeniedError + * If the user cannot view the stream. + */ + public function show(Request $request): Response + { + $user = auth\CurrentUser::require(); + $stream = models\Stream::requireFromRequest($request); + + auth\Access::require($user, 'view', $stream); + + $stream_view = models\StreamView::buildFromRequest($stream, $request); + + return Response::ok('streams/show.html.twig', [ + 'stream_view' => $stream_view, + ]); + } +} diff --git a/src/controllers/streams/Sources.php b/src/controllers/streams/Sources.php new file mode 100644 index 000000000..04dd7e135 --- /dev/null +++ b/src/controllers/streams/Sources.php @@ -0,0 +1,138 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class Sources extends BaseController +{ + /** + * @request_param string id + * + * @response 200 + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream doesn't exist. + * @throws auth\AccessDeniedError + * If the user cannot update the stream. + */ + public function edit(Request $request): Response + { + $user = auth\CurrentUser::require(); + $stream = models\Stream::requireFromRequest($request); + + auth\Access::require($user, 'update', $stream); + + $suggested_sources = $user->followedCollections(['number_links']); + $suggested_sources = utils\Sorter::localeSort($suggested_sources, 'name'); + + $existing_sources = $stream->sources(); + + $suggested_sources = array_udiff( + $suggested_sources, + $existing_sources, + function (models\Collection $source1, models\Collection $source2): int { + return $source1->id <=> $source2->id; + }, + ); + + return Response::ok('streams/sources/edit.html.twig', [ + 'stream' => $stream, + 'suggested_sources' => $suggested_sources, + ]); + } + + /** + * @request_param string id + * @request_param string source_id + * @request_param string csrf_token + * + * @response 302 /streams/:id/sources/edit + * @flash notification.error + * If at least one of the parameters is invalid. + * @response 302 /streams/:id/sources/edit + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream or the source don't exist. + * @throws auth\AccessDeniedError + * If the user cannot update the stream or cannot view the source. + */ + public function add(Request $request): Response + { + $user = auth\CurrentUser::require(); + + $stream = models\Stream::requireFromRequest($request); + $source = models\Collection::requireFromRequest($request, parameter: 'source_id'); + + auth\Access::require($user, 'update', $stream); + auth\Access::require($user, 'view', $source); + + $form = new forms\streams\AddSource(); + $form->handleRequest($request); + + if (!$form->validate()) { + utils\Notification::error($form->error('@base')); + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + $stream->addSource($source); + + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + /** + * @request_param string id + * @request_param string source_id + * @request_param string csrf_token + * + * @response 302 /streams/:id/sources/edit + * @flash notification.error + * If at least one of the parameters is invalid. + * @response 302 /streams/:id/sources/edit + * On success. + * + * @throws auth\MissingCurrentUserError + * If the user is not connected. + * @throws \Minz\Errors\MissingRecordError + * If the stream or the source don't exist. + * @throws auth\AccessDeniedError + * If the user cannot update the stream. + */ + public function remove(Request $request): Response + { + $user = auth\CurrentUser::require(); + + $stream = models\Stream::requireFromRequest($request); + $source = models\Collection::requireFromRequest($request, parameter: 'source_id'); + + auth\Access::require($user, 'update', $stream); + + $form = new forms\streams\RemoveSource(); + $form->handleRequest($request); + + if (!$form->validate()) { + utils\Notification::error($form->error('@base')); + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } + + $stream->removeSource($source); + + return Response::redirect('edit stream sources', ['id' => $stream->id]); + } +} diff --git a/src/forms/streams/AddSource.php b/src/forms/streams/AddSource.php new file mode 100644 index 000000000..c74a6f4e6 --- /dev/null +++ b/src/forms/streams/AddSource.php @@ -0,0 +1,13 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class AddSource extends BaseForm +{ +} diff --git a/src/forms/streams/RemoveSource.php b/src/forms/streams/RemoveSource.php new file mode 100644 index 000000000..efceec85c --- /dev/null +++ b/src/forms/streams/RemoveSource.php @@ -0,0 +1,13 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class RemoveSource extends BaseForm +{ +} diff --git a/src/forms/streams/Stream.php b/src/forms/streams/Stream.php new file mode 100644 index 000000000..b79918db2 --- /dev/null +++ b/src/forms/streams/Stream.php @@ -0,0 +1,26 @@ + + * + * @author Marien Fressinaud + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class Stream extends BaseForm +{ + #[Form\Field(transform: 'trim')] + public string $name = ''; + + #[Form\Field(transform: 'trim')] + public string $description = ''; + + public int $stream_name_max_length = models\Stream::NAME_MAX_LENGTH; +} diff --git a/src/migrations/Migration202606240001CreateStreams.php b/src/migrations/Migration202606240001CreateStreams.php new file mode 100644 index 000000000..a2910b048 --- /dev/null +++ b/src/migrations/Migration202606240001CreateStreams.php @@ -0,0 +1,59 @@ +exec(<<<'SQL' + CREATE TABLE streams ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_public BOOLEAN NOT NULL DEFAULT false, + image_filename TEXT, + + user_id TEXT REFERENCES users ON DELETE CASCADE ON UPDATE CASCADE + ); + + CREATE INDEX idx_streams_user_id ON streams(user_id); + CREATE INDEX idx_streams_image_filename ON streams(image_filename) WHERE image_filename IS NOT NULL; + + CREATE TABLE streams_to_follows ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + stream_id TEXT REFERENCES streams ON DELETE CASCADE ON UPDATE CASCADE, + follow_id BIGINT REFERENCES followed_collections ON DELETE CASCADE ON UPDATE CASCADE + ); + + CREATE UNIQUE INDEX idx_streams_to_follows ON streams_to_follows(stream_id, follow_id); + CREATE INDEX idx_streams_to_follows_follow_id ON streams_to_follows(follow_id); + SQL); + + return true; + } + + public function rollback(): bool + { + $database = \Minz\Database::get(); + + $database->exec(<<<'SQL' + DROP INDEX idx_streams_to_follows; + DROP INDEX idx_streams_to_follows_follow_id; + + DROP TABLE streams_to_follows; + + DROP INDEX idx_streams_user_id; + DROP INDEX idx_streams_image_filename; + + DROP TABLE streams; + SQL); + + return true; + } +} diff --git a/src/models/FollowedCollection.php b/src/models/FollowedCollection.php index 381e8960a..b02716b13 100644 --- a/src/models/FollowedCollection.php +++ b/src/models/FollowedCollection.php @@ -51,4 +51,14 @@ public function __construct(string $user_id, string $collection_id) $this->user_id = $user_id; $this->collection_id = $collection_id; } + + public static function findOrCreate(User $user, Collection $collection): self + { + return self::findOrCreateBy([ + 'user_id' => $user->id, + 'collection_id' => $collection->id, + ], [ + 'time_filter' => 'normal', + ]); + } } diff --git a/src/models/Stream.php b/src/models/Stream.php new file mode 100644 index 000000000..3c8d2aea3 --- /dev/null +++ b/src/models/Stream.php @@ -0,0 +1,157 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +#[Database\Table(name: 'streams')] +class Stream +{ + use Database\Recordable; + use Database\Resource; + use Validable; + use utils\Memoizer; + + public const NAME_MAX_LENGTH = 100; + + #[Database\Column] + public string $id; + + #[Database\Column] + public \DateTimeImmutable $created_at; + + #[Database\Column] + #[Validable\Presence( + message: new Translatable('The name is required.'), + )] + #[Validable\Length( + max: self::NAME_MAX_LENGTH, + message: new Translatable('The name must be less than {max} characters.'), + )] + public string $name = ''; + + #[Database\Column] + public string $description = ''; + + #[Database\Column] + public bool $is_public = false; + + #[Database\Column] + public ?string $image_filename = null; + + #[Database\Column] + public string $user_id; + + public function __construct(User $user) + { + $this->id = \Minz\Random::timebased(); + $this->setOwner($user); + } + + public function name(): string + { + return $this->name; + } + + public function description(): string + { + return $this->description; + } + + /** + * Return the description as HTML (from Markdown). + */ + public function descriptionAsHtml(): string + { + $markdown = new utils\MiniMarkdown(context_user: $this->owner()); + return $markdown->text($this->description()); + } + + public function url(): string + { + return \Minz\Url::absoluteFor('stream', ['id' => $this->id]); + } + + public function owner(): User + { + return $this->memoize('owner', function (): User { + return User::require($this->user_id); + }); + } + + public function setOwner(User $user): void + { + $this->user_id = $user->id; + $this->memoizeValue('owner', $user); + } + + /** + * @return Collection[] + */ + public function sources(): array + { + return $this->memoize('sources', function (): array { + $collections = Collection::listByStream($this); + return utils\Sorter::localeSort($collections, 'name'); + }); + } + + public function hasSource(Collection $source): bool + { + return StreamToFollow::find($this, $source) !== null; + } + + public function addSource(Collection $source): void + { + StreamToFollow::findOrCreate($this, $source); + $this->unmemoize('sources'); + } + + public function removeSource(Collection $source): void + { + $stream_to_follow = StreamToFollow::find($this, $source); + if ($stream_to_follow) { + $stream_to_follow->remove(); + } + $this->unmemoize('sources'); + } + + public function publicationFrequencyPerYear(): int + { + $sources = $this->sources(); + return array_sum(array_column($sources, 'publication_frequency_per_year')); + } + + /** + * @param array{ + * context_user?: ?User, + * at?: \DateTimeImmutable, + * days?: int, + * } $options + * + * @return Link[] + */ + public function links(array $options = []): array + { + return Link::listByStream($this, $options); + } + + /** + * Return a tag URI that can be used as Atom id + * + * @see https://www.rfc-editor.org/rfc/rfc4151.txt + */ + public function tagUri(): string + { + $host = \App\Configuration::$url_options['host']; + $date = $this->created_at->format('Y-m-d'); + return "tag:{$host},{$date}:streams/{$this->id}"; + } +} diff --git a/src/models/StreamToFollow.php b/src/models/StreamToFollow.php new file mode 100644 index 000000000..a4c1e5aaa --- /dev/null +++ b/src/models/StreamToFollow.php @@ -0,0 +1,68 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +#[Database\Table(name: 'streams_to_follows')] +class StreamToFollow +{ + use Database\Recordable; + + #[Database\Column] + public int $id; + + #[Database\Column] + public \DateTimeImmutable $created_at; + + #[Database\Column] + public string $stream_id; + + #[Database\Column] + public int $follow_id; + + public static function find(Stream $stream, Collection $source): ?self + { + $sql = << $stream->owner()->id, + 'source_id' => $source->id, + 'stream_id' => $stream->id, + ]; + + $database = \Minz\Database::get(); + $statement = $database->prepare($sql); + $statement->execute($parameters); + + $result = $statement->fetch(); + if (is_array($result)) { + return self::fromDatabaseRow($result); + } else { + return null; + } + } + + public static function findOrCreate(Stream $stream, Collection $source): self + { + $follow = FollowedCollection::findOrCreate($stream->owner(), $source); + + return self::findOrCreateBy([ + 'stream_id' => $stream->id, + 'follow_id' => $follow->id, + ]); + } +} diff --git a/src/models/StreamView.php b/src/models/StreamView.php new file mode 100644 index 000000000..5ad92bf9c --- /dev/null +++ b/src/models/StreamView.php @@ -0,0 +1,40 @@ + + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class StreamView +{ + public function __construct( + public readonly Stream $stream, + public readonly \DateTimeImmutable $at, + public readonly int $days, + ) { + } + + public static function buildFromRequest(Stream $stream, Request $request): self + { + $today = \Minz\Time::now(); + $at = $request->parameters->getDatetime('at', $today, 'Y-m-d'); + $days = $request->parameters->getInteger('days', 1); + + return new self($stream, $at, $days); + } + + public function linksTimeline(?User $context_user = null): utils\LinksTimeline + { + $links = $this->stream->links([ + 'context_user' => $context_user, + 'at' => $this->at, + 'days' => $this->days, + ]); + + return new utils\LinksTimeline($links); + } +} diff --git a/src/models/User.php b/src/models/User.php index ca450816c..a1b63a205 100644 --- a/src/models/User.php +++ b/src/models/User.php @@ -206,6 +206,11 @@ public function isBetaEnabled(): bool return FeatureFlag::isEnabled('beta', $this->id); } + public function isAlphaEnabled(): bool + { + return FeatureFlag::isEnabled('alpha', $this->id); + } + public function isOwning(Link|Collection $object): bool { return $object->user_id === $this->id; @@ -790,6 +795,27 @@ private function unmemoizeUrlStatusesOfLinks(array $links): void } } + public function initStream(): Stream + { + return new Stream($this); + } + + /** + * Return the list of streams owned by the user. + * + * @return Stream[] + */ + public function streams(): array + { + return $this->memoize('streams', function (): array { + $streams = Stream::listBy([ + 'user_id' => $this->id, + ]); + + return utils\Sorter::localeSort($streams, 'name'); + }); + } + /** * Set the user password. */ diff --git a/src/models/dao/Collection.php b/src/models/dao/Collection.php index 4abc7b6e1..b58d713c9 100644 --- a/src/models/dao/Collection.php +++ b/src/models/dao/Collection.php @@ -2,6 +2,7 @@ namespace App\models\dao; +use App\models; use Minz\Database; /** @@ -395,6 +396,33 @@ public static function listComputedSharedByUserIdTo( return self::fromDatabaseRows($statement->fetchAll()); } + /** + * List the collections present in the given stream. + * + * @return self[] + */ + public static function listByStream(models\Stream $stream): array + { + $parameters = [ + ':stream_id' => $stream->id, + ]; + + $sql = <<prepare($sql); + $statement->execute($parameters); + + return self::fromDatabaseRows($statement->fetchAll()); + } + /** * Return whether the link is in a collection owned by the given user or not. */ diff --git a/src/models/dao/Link.php b/src/models/dao/Link.php index cf08d4efe..1e4d9f17d 100644 --- a/src/models/dao/Link.php +++ b/src/models/dao/Link.php @@ -636,6 +636,89 @@ public static function countRead(models\User $user): int return intval($statement->fetchColumn()); } + /** + * Return the list of links of the given stream. + * + * @param array{ + * context_user?: ?models\User, + * at?: \DateTimeImmutable, + * days?: int, + * } $options + * + * @return models\Link[] + */ + public static function listByStream(models\Stream $stream, array $options): array + { + $default_options = [ + 'context_user' => null, + 'at' => \Minz\Time::now(), + 'days' => 1, + ]; + $options = array_merge($default_options, $options); + + $parameters = [ + ':stream_id' => $stream->id, + ]; + + // Calculate the time span interval to get the links. + $start = $options['at']->modify('00:00:00'); + $end = $start->modify('23:59:59'); + + $days = min(7, max(1, $options['days'])); + $days = $days - 1; // the actual interval is already of 1 day. + if ($days > 0) { + $start = $start->modify("-{$days} days"); + } + + $parameters[':at_start'] = $start->format(Database\Column::DATETIME_FORMAT); + $parameters[':at_end'] = $end->format(Database\Column::DATETIME_FORMAT); + + // Create the visibility clause, adapted if a context user is passed. + $visibility_clause = 'AND (l.is_hidden = false AND c.is_public = true)'; + + if ($options['context_user']) { + $parameters[':user_id'] = $options['context_user']->id; + + $visibility_clause = <<= :at_start AND lc.created_at <= :at_end + + {$visibility_clause} + + ORDER BY published_at DESC, l.id + SQL; + + $database = Database::get(); + $statement = $database->prepare($sql); + $statement->execute($parameters); + + return self::fromDatabaseRows($statement->fetchAll()); + } + public function numberCollectionsForUser(\App\models\User $user): int { $sql = <<isAlphaEnabled()) { + $new_stream_action = new ItemAction( + label: TwigExtension::translate('New stream'), + url: \Minz\Url::for('new stream'), + icon: 'plus', + ); + + $stream_items = []; + + foreach ($current_user->streams() as $stream) { + $stream_items[] = new Item( + label: $stream->name, + key: $stream->id, + url: \Minz\Url::for('stream', ['id' => $stream->id]), + ); + } + + if (count($stream_items) === 0) { + $stream_items[] = new ItemPlaceholder( + TwigExtension::translate('Create a stream to get started.'), + ); + } + + $elements[] = new ItemGroup( + label: TwigExtension::translate('Streams'), + items: $stream_items, + action: $new_stream_action, + ); + } + return $elements; } } diff --git a/src/schema.sql b/src/schema.sql index c39b55096..fa513b059 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -232,6 +232,31 @@ CREATE TABLE followed_collections ( CREATE UNIQUE INDEX idx_followed_collections ON followed_collections(user_id, collection_id); +CREATE TABLE streams ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_public BOOLEAN NOT NULL DEFAULT false, + image_filename TEXT, + + user_id TEXT REFERENCES users ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX idx_streams_user_id ON streams(user_id); +CREATE INDEX idx_streams_image_filename ON streams(image_filename) WHERE image_filename IS NOT NULL; + +CREATE TABLE streams_to_follows ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + stream_id TEXT REFERENCES streams ON DELETE CASCADE ON UPDATE CASCADE, + follow_id BIGINT REFERENCES followed_collections ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX idx_streams_to_follows ON streams_to_follows(stream_id, follow_id); +CREATE INDEX idx_streams_to_follows_follow_id ON streams_to_follows(follow_id); + CREATE TABLE collection_shares ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL, diff --git a/src/views/streams/_form.html.twig b/src/views/streams/_form.html.twig new file mode 100644 index 000000000..504a6c0af --- /dev/null +++ b/src/views/streams/_form.html.twig @@ -0,0 +1,80 @@ +
+ {{ include('alerts/_error.html.twig', { message: form.error('@base') }) }} + +
+ + + {% if form.isInvalid('name') %} +

+ {{ t('Error:') }} + {{ form.error('name') }} +

+ {% endif %} + + +
+ +
+ + + {% if form.isInvalid('description') %} +

+ {{ t('Error:') }} + {{ form.error('description') }} +

+ {% endif %} + + + +

+ {{ t('You can format the description in Markdown.') }} +

+
+ +
+ +
+ + +
diff --git a/src/views/streams/new.html.twig b/src/views/streams/new.html.twig new file mode 100644 index 000000000..99a6fd0d8 --- /dev/null +++ b/src/views/streams/new.html.twig @@ -0,0 +1,11 @@ +{% extends 'layouts/modal.html.twig' %} + +{% block title %}{{ t('New stream') }}{% endblock %} + +{% block body %} + {{ include('streams/_form.html.twig', { + form: form, + action: url('create stream'), + submit_label: t('Create the stream'), + }) }} +{% endblock %} diff --git a/src/views/streams/show.html.twig b/src/views/streams/show.html.twig new file mode 100644 index 000000000..34ea8333f --- /dev/null +++ b/src/views/streams/show.html.twig @@ -0,0 +1,123 @@ +{% extends 'layouts/application.html.twig' %} + +{% set stream = stream_view.stream %} + +{% block title %}{{ stream.name }}{% endblock %} +{% block canonical %}{{ url_full('stream', { id: stream.id }) }}{% endblock %} + +{% block layout_header %} + {{ include('layouts/_header.html.twig', { page: 'stream', tab: 'reading' }) }} +{% endblock %} + +{% block layout_sidenav %} + {{ include('layouts/_sidenav.html.twig', { navigation: reading_navigation(current: stream.id) }) }} +{% endblock %} + +{% block back %} + {{ include('layouts/_back.html.twig', { title: stream.name, reset: true }) }} +{% endblock %} + +{% block body %} +
+
+ +
+ +
+

{{ stream.name }}

+ +

+ + {{ icon('frequency') }} + {{ stream.publicationFrequencyPerYear | format_publication_frequency }} + +

+ +

+ {{ t('By') }} + + + {{- stream.owner.username -}} + +

+ + {% if stream.description %} +
+ {{ stream.descriptionAsHtml | raw }} +
+ {% endif %} +
+
+ + {% if can(app.user, 'update', stream) %} + + {% endif %} + + {% set links_timeline = stream_view.linksTimeline(context_user: app.user) %} + + {% if not links_timeline.empty %} +
+ {% for date_group in links_timeline.datesGroups %} +
+
+

+ {{ d(date_group.date, 'dd MMMM') }} +

+ +
+
+ +
+ {% set links = date_group.links %} + + {% if links %} +
+ {% for link in date_group.links %} + {{ include('links/_link.html.twig', { + link: link, + display_read_later: 'auto', + display_mark_as_read: 'auto', + storing_must_mark_as_read: true, + })}} + {% endfor %} +
+ {% endif %} + + {% for source_group in date_group.sourceGroups %} +
+
+

+ {{ source_group.source.name() }} +

+ + + {{ t('%s link', '%s links', source_group.links | length, [source_group.links | length]) }} + +
+ +
+ {% for link in source_group.links %} + {{ include('links/_link.html.twig', { + link: link, + display_read_later: 'auto', + display_mark_as_read: 'auto', + storing_must_mark_as_read: true, + }) }} + {% endfor %} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/src/views/streams/sources/_source.html.twig b/src/views/streams/sources/_source.html.twig new file mode 100644 index 000000000..0b73e35e7 --- /dev/null +++ b/src/views/streams/sources/_source.html.twig @@ -0,0 +1,59 @@ +
+
+ + +
+

+ + {{ source.name }} + +

+ +

+ {% if source.isFeed %} + + {{ source.feedWebsiteHost }} + + {% else %} + {{ t('By') }} + {%- for user in source.publishers -%} + {% if not loop.first %}, {% endif %} + + + {{- user.username -}} + + {%- endfor -%} + {% endif %} +

+ +

+ + {{ icon('frequency') }} + {{ source.publication_frequency_per_year | format_publication_frequency }} + +

+
+
+ + {% if action === 'add' %} +
+ + + +
+ {% elseif action === 'remove' %} +
+ + + +
+ {% endif %} +
diff --git a/src/views/streams/sources/edit.html.twig b/src/views/streams/sources/edit.html.twig new file mode 100644 index 000000000..6a379e785 --- /dev/null +++ b/src/views/streams/sources/edit.html.twig @@ -0,0 +1,72 @@ +{% extends 'layouts/application.html.twig' %} + +{% set title = t('Sources of %s', [stream.name]) %} + +{% block title %}{{ title }}{% endblock %} +{% block canonical %}{{ url_full('edit stream sources', { id: stream.id }) }}{% endblock %} + +{% block layout_header %} + {{ include('layouts/_header.html.twig', { page: 'stream sources', tab: 'reading' }) }} +{% endblock %} + +{% block back %} + {{ include('layouts/_back.html.twig', { title: title }) }} +{% endblock %} + +{% block body %} +

{{ title }}

+ +

+ {{ t('Add or remove sources to customise your stream.') }} +

+ +
+
+
+
+

{{ t('Sources to add') }}

+
+ +
+ {% for source in suggested_sources %} + {{ include('streams/sources/_source.html.twig', { action: 'add' }) }} + {% endfor %} +
+
+ +
+ {% set sources = stream.sources %} + +
+

{{ t('Sources of the stream') }}

+ +

+ + {{ t('%d source', '%d sources', sources | length, [sources | length]) }} + + + + {{ icon('frequency') }} + {{ stream.publicationFrequencyPerYear | format_publication_frequency }} + +

+
+ + {% if sources %} +
+ {% for source in sources %} + {{ include('streams/sources/_source.html.twig', { action: 'remove' }) }} + {% endfor %} +
+ {% endif %} +
+
+ +

+ + {{ t('Continue to the stream') }} + {{ icon('arrow-right') }} + +

+
+{% endblock %} diff --git a/tests/controllers/StreamsTest.php b/tests/controllers/StreamsTest.php new file mode 100644 index 000000000..0ccd596a7 --- /dev/null +++ b/tests/controllers/StreamsTest.php @@ -0,0 +1,196 @@ +login(); + + $response = $this->appRun('GET', '/streams/new'); + + $this->assertResponseCode($response, 200); + $this->assertResponseContains($response, 'New stream'); + $this->assertResponseTemplateName($response, 'streams/new.html.twig'); + } + + public function testNewRedirectsIfNotConnected(): void + { + $response = $this->appRun('GET', '/streams/new'); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2Fstreams%2Fnew'); + } + + public function testCreateCreatesStreamAndRedirects(): void + { + $user = $this->login(); + /** @var string */ + $name = $this->fake('words', 3, true); + /** @var string */ + $description = $this->fake('sentence'); + + $this->assertSame(0, models\Stream::count()); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'name' => $name, + 'description' => $description, + ]); + + $this->assertSame(1, models\Stream::count()); + $stream = models\Stream::take(); + $this->assertNotNull($stream); + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertSame($name, $stream->name); + $this->assertSame($description, $stream->description); + $this->assertFalse($stream->is_public); + } + + public function testCreateRedirectsIfNotConnected(): void + { + /** @var string */ + $name = $this->fake('words', 3, true); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'name' => $name, + 'description' => $description, + ]); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2Fstreams%2Fnew'); + $this->assertSame(0, models\Stream::count()); + } + + public function testCreateFailsIfCsrfIsInvalid(): void + { + $user = $this->login(); + /** @var string */ + $name = $this->fake('words', 3, true); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => 'not the token', + 'name' => $name, + 'description' => $description, + ]); + + $this->assertResponseCode($response, 400); + $this->assertResponseContains($response, 'A security verification failed'); + $this->assertSame(0, models\Stream::count()); + } + + public function testCreateFailsIfNameIsInvalid(): void + { + $user = $this->login(); + /** @var string */ + $name = $this->fake('words', 100, true); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'name' => $name, + 'description' => $description, + ]); + + $this->assertResponseCode($response, 400); + $this->assertResponseContains($response, 'The name must be less than 100 characters'); + $this->assertSame(0, models\Stream::count()); + } + + public function testCreateFailsIfNameIsMissing(): void + { + $user = $this->login(); + /** @var string */ + $description = $this->fake('sentence'); + + $response = $this->appRun('POST', '/streams/new', [ + 'csrf_token' => $this->csrfToken(forms\streams\Stream::class), + 'description' => $description, + ]); + + $this->assertResponseCode($response, 400); + $this->assertResponseContains($response, 'The name is required'); + $this->assertSame(0, models\Stream::count()); + } + + public function testShowRendersCorrectly(): void + { + $user = $this->login(); + /** @var string */ + $link_title = $this->fake('words', 3, true); + $feed = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link = LinkFactory::create([ + 'user_id' => $feed->user_id, + 'title' => $link_title, + 'is_hidden' => false, + ]); + $feed->addLinks([$link], at: \Minz\Time::now()); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $stream->addSource($feed); + + $response = $this->appRun('GET', "/streams/{$stream->id}"); + + $this->assertResponseCode($response, 200); + $this->assertResponseContains($response, $link_title); + $this->assertResponseTemplateName($response, 'streams/show.html.twig'); + } + + public function testShowHidesHiddenLinksInPublicCollections(): void + { + $user = $this->login(); + /** @var string */ + $link_title = $this->fake('words', 3, true); + $feed = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link = LinkFactory::create([ + 'user_id' => $feed->user_id, + 'title' => $link_title, + 'is_hidden' => true, + ]); + $feed->addLinks([$link], at: \Minz\Time::now()); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + + $response = $this->appRun('GET', "/streams/{$stream->id}"); + + $this->assertResponseCode($response, 200); + $this->assertResponseNotContains($response, $link_title); + } + + public function testShowFailsIfStreamDoesNotExist(): void + { + $this->login(); + + $response = $this->appRun('GET', '/streams/unknown'); + + $this->assertResponseCode($response, 404); + } +} diff --git a/tests/controllers/streams/SourcesTest.php b/tests/controllers/streams/SourcesTest.php new file mode 100644 index 000000000..d32aa987b --- /dev/null +++ b/tests/controllers/streams/SourcesTest.php @@ -0,0 +1,405 @@ +login(); + /** @var string */ + $stream_name = $this->fake('words', 3, true); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + 'name' => $stream_name, + ]); + /** @var string */ + $source_name = $this->fake('words', 3, true); + /** @var string */ + $feed_url = $this->fake('url'); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'name' => $source_name, + 'feed_site_url' => $feed_url, + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('GET', "/streams/{$stream->id}/sources/edit"); + + $this->assertResponseCode($response, 200); + $this->assertResponseContains($response, "Sources of {$stream_name}"); + $this->assertResponseContains($response, $source_name); + $this->assertResponseTemplateName($response, 'streams/sources/edit.html.twig'); + } + + public function testEditRedirectsIfNotConnected(): void + { + $user = UserFactory::create(); + /** @var string */ + $stream_name = $this->fake('words', 3, true); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + 'name' => $stream_name, + ]); + /** @var string */ + $source_name = $this->fake('words', 3, true); + /** @var string */ + $feed_url = $this->fake('url'); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'name' => $source_name, + 'feed_site_url' => $feed_url, + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('GET', "/streams/{$stream->id}/sources/edit"); + + $this->assertResponseCode($response, 302, "/login?redirect_to=%2Fstreams%2F{$stream->id}%2Fsources%2Fedit"); + } + + public function testEditFailsIfTheStreamDoesNotExist(): void + { + $user = $this->login(); + + $response = $this->appRun('GET', '/streams/unknown/sources/edit'); + + $this->assertResponseCode($response, 404); + } + + public function testEditFailsIfTheUserCannotUpdateTheStream(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + /** @var string */ + $stream_name = $this->fake('words', 3, true); + $stream = StreamFactory::create([ + 'user_id' => $other_user->id, + 'name' => $stream_name, + ]); + /** @var string */ + $source_name = $this->fake('words', 3, true); + /** @var string */ + $feed_url = $this->fake('url'); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'name' => $source_name, + 'feed_site_url' => $feed_url, + 'is_public' => true, + ]); + $other_user->follow($source->id); + + $response = $this->appRun('GET', "/streams/{$stream->id}/sources/edit"); + + $this->assertResponseCode($response, 403); + } + + public function testAddAddsTheSourceToTheStream(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $this->assertFalse($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertTrue($stream->hasSource($source)); + } + + public function testAddWorksIfTheSourceIsNotFollowedYet(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $this->assertFalse($stream->hasSource($source)); + $this->assertFalse($user->isFollowing($source->id)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertTrue($stream->hasSource($source)); + $this->assertTrue($user->isFollowing($source->id)); + } + + public function testAddRedirectsIfNotConnected(): void + { + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2F'); + $this->assertFalse($stream->hasSource($source)); + } + + public function testAddFailsIfTheStreamDoesNotExist(): void + { + $user = $this->login(); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $response = $this->appRun('POST', "/streams/unknown/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testAddFailsIfTheSourceDoesNotExist(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/unknown/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testAddFailsIfTheUserCannotUpdateTheStream(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $other_user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 403); + $this->assertFalse($stream->hasSource($source)); + } + + public function testAddFailsIfTheUserCannotViewTheSource(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => $this->csrfToken(forms\streams\AddSource::class), + ]); + + $this->assertResponseCode($response, 403); + $this->assertFalse($stream->hasSource($source)); + } + + public function testAddFailsIfCsrfIsInvalid(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $user->follow($source->id); + + $this->assertFalse($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/add", [ + 'csrf_token' => 'not the token', + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertStringContainsString('A security verification failed', utils\Notification::popError()); + $this->assertFalse($stream->hasSource($source)); + } + + public function testRemoveRemovesTheSourceFromTheStream(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $this->assertTrue($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertFalse($stream->hasSource($source)); + } + + public function testRemoveDoesNotFailsIfTheSourceIsNotInTheStream(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $this->assertFalse($stream->hasSource($source)); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertFalse($stream->hasSource($source)); + } + + public function testRemoveRedirectsIfNotConnected(): void + { + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 302, '/login?redirect_to=%2F'); + $this->assertTrue($stream->hasSource($source)); + } + + public function testRemoveFailsIfTheStreamDoesNotExist(): void + { + $user = $this->login(); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + + $response = $this->appRun('POST', "/streams/unknown/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testRemoveFailsIfTheSourceDoesNotExist(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/unknown/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 404); + } + + public function testRemoveFailsIfTheUserCannotUpdateTheStream(): void + { + $user = $this->login(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $other_user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => $this->csrfToken(forms\streams\RemoveSource::class), + ]); + + $this->assertResponseCode($response, 403); + $this->assertTrue($stream->hasSource($source)); + } + + public function testRemoveFailsIfCsrfIsInvalid(): void + { + $user = $this->login(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $stream->addSource($source); + + $response = $this->appRun('POST', "/streams/{$stream->id}/sources/{$source->id}/remove", [ + 'csrf_token' => 'not the token', + ]); + + $this->assertResponseCode($response, 302, "/streams/{$stream->id}/sources/edit"); + $this->assertStringContainsString('A security verification failed', utils\Notification::popError()); + $this->assertTrue($stream->hasSource($source)); + } +} diff --git a/tests/factories/StreamFactory.php b/tests/factories/StreamFactory.php new file mode 100644 index 000000000..a8fce0597 --- /dev/null +++ b/tests/factories/StreamFactory.php @@ -0,0 +1,47 @@ + + * + * @author Marien Fressinaud + * @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL + */ +class StreamFactory extends Database\Factory +{ + public static function model(): string + { + return models\Stream::class; + } + + public static function values(): array + { + $faker = \Faker\Factory::create(); + + return [ + 'id' => function (): string { + return \Minz\Random::timebased(); + }, + + 'created_at' => function () use ($faker) { + return $faker->dateTime; + }, + + 'name' => function () use ($faker) { + return $faker->words(3, true); + }, + + 'is_public' => function () use ($faker) { + return $faker->boolean; + }, + + 'user_id' => function () { + return UserFactory::create()->id; + }, + ]; + } +} diff --git a/tests/models/StreamViewTest.php b/tests/models/StreamViewTest.php new file mode 100644 index 000000000..ea420e0dc --- /dev/null +++ b/tests/models/StreamViewTest.php @@ -0,0 +1,333 @@ +fake('date'); + /** @var int */ + $days = $this->fake('numberBetween', 1, 7); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date, + 'days' => $days, + ]); + $stream = StreamFactory::create(); + + $stream_view = StreamView::buildFromRequest($stream, $request); + + $this->assertSame($date, $stream_view->at->format('Y-m-d')); + $this->assertSame($days, $stream_view->days); + } + + public function testBuildFromRequestHasDefaultValues(): void + { + $now = \Minz\Time::now(); + $request = new \Minz\Request('GET', '/stream', []); + $stream = StreamFactory::create(); + + $stream_view = StreamView::buildFromRequest($stream, $request); + + $this->assertSame($now->format('Y-m-d'), $stream_view->at->format('Y-m-d')); + $this->assertSame(1, $stream_view->days); + } + + public function testLinksTimelineListsLinksOfSelectedDay(): void + { + /** @var \DateTimeImmutable */ + $date_1 = $this->fake('dateTime'); + $date_2 = $date_1->modify('-1 day'); + $date_3 = $date_1->modify('-2 days'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date_1->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_3 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source->addLinks([$link_1], at: $date_1); + $source->addLinks([$link_2], at: $date_2); + $source->addLinks([$link_3], at: $date_3); + $stream->addSource($source); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineListsLinksOfSelectedDayPlusDays(): void + { + /** @var \DateTimeImmutable */ + $date_1 = $this->fake('dateTime'); + $date_2 = $date_1->modify('-1 day'); + $date_3 = $date_1->modify('-2 days'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date_1->format('Y-m-d'), + 'days' => 2, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'feed', + 'is_public' => true, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_3 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source->addLinks([$link_1], at: $date_1); + $source->addLinks([$link_2], at: $date_2); + $source->addLinks([$link_3], at: $date_3); + $stream->addSource($source); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(2, count($date_groups)); + + $date_group_1 = array_shift($date_groups); + $this->assertNotNull($date_group_1); + $source_groups_1 = $date_group_1->sourceGroups(); + $this->assertSame(1, count($source_groups_1)); + $source_group_1 = $source_groups_1[0]; + $this->assertSame($source->id, $source_group_1->source->id); + $this->assertSame(1, count($source_group_1->links)); + $this->assertSame($link_1->id, $source_group_1->links[0]->id); + + $date_group_2 = array_shift($date_groups); + $this->assertNotNull($date_group_2); + $source_groups_2 = $date_group_2->sourceGroups(); + $this->assertSame(1, count($source_groups_2)); + $source_group_2 = $source_groups_2[0]; + $this->assertSame($source->id, $source_group_2->source->id); + $this->assertSame(1, count($source_group_2->links)); + $this->assertSame($link_2->id, $source_group_2->links[0]->id); + } + + public function testLinksTimelineCanListLinksFromPrivateSourceIfTheUserOwnsTheSource(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source_1 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $user->id, + ]); + $source_2 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source_1->addLinks([$link_1], at: $date); + $source_2->addLinks([$link_2], at: $date); + $stream->addSource($source_1); + $stream->addSource($source_2); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline($user); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source_1->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineCanListLinksFromPrivateSourceIfTheUserHasAccessToTheSource(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $other_user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source_1 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + $source_2 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + 'user_id' => $other_user->id, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source_1->addLinks([$link_1], at: $date); + $source_2->addLinks([$link_2], at: $date); + $stream->addSource($source_1); + $stream->addSource($source_2); + $source_1->shareWith($user, 'read'); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline($user); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source_1->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineExcludesHiddenLinks(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => true, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => true, + ]); + $source->addLinks([$link_1, $link_2], at: $date); + $stream->addSource($source); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } + + public function testLinksTimelineExcludesPrivateSources(): void + { + /** @var \DateTimeImmutable */ + $date = $this->fake('dateTime'); + $request = new \Minz\Request('GET', '/stream', [ + 'at' => $date->format('Y-m-d'), + 'days' => 1, + ]); + $user = UserFactory::create(); + $stream = StreamFactory::create([ + 'user_id' => $user->id, + ]); + $source_1 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => true, + ]); + $source_2 = CollectionFactory::create([ + 'type' => 'collection', + 'is_public' => false, + ]); + $link_1 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $link_2 = LinkFactory::create([ + 'is_hidden' => false, + ]); + $source_1->addLinks([$link_1], at: $date); + $source_2->addLinks([$link_2], at: $date); + $stream->addSource($source_1); + $stream->addSource($source_2); + $stream_view = StreamView::buildFromRequest($stream, $request); + + $links_timeline = $stream_view->linksTimeline(); + + $date_groups = $links_timeline->datesGroups(); + $this->assertSame(1, count($date_groups)); + $date_group = array_shift($date_groups); + $this->assertNotNull($date_group); + $source_groups = $date_group->sourceGroups(); + $this->assertSame(1, count($source_groups)); + $source_group = $source_groups[0]; + $this->assertSame($source_1->id, $source_group->source->id); + $this->assertSame(1, count($source_group->links)); + $this->assertSame($link_1->id, $source_group->links[0]->id); + } +}