Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Moduly / Blog

Blog#

Redakční obsah webu — články, novinky, akce, galerie, karusely, kniha návštěv a kontaktní formulář. Kód: app/UI/Api/Blog/, front app/UI/Front/Blog/.

Blog je zároveň referenční modul systému: byl první zapojený do Elasticsearch a jeho front presenter je vzorem rozkladu do traitů. Kdo hledá, jak se v tomhle CMS něco dělá správně, začíná tady.

Z čeho se modul skládá#

Skupina Entity Poznámka
obsah Article, News, Event, Intro + jejich *Text každý má vlastní kategorii i výpis
média Gallery + GalleryAssetRel, Carousel + CarouselItem
taxonomie Category (strom), Tag (plochý seznam)
interakce Guestbook + GuestbookMessage, ContactMessage, ArticleDiscussionMessage
doplňky FlashMessage, ArticlePollRel, ArticleAdvertRel provázání na ankety a reklamu

Dva vzory, ze kterých se kopíruje#

Vzor Soubor Proč zrovna tenhle
zdroj pro Elasticsearch app/UI/Api/Blog/Models/Search/ArticleDocumentSource.php článek byl první zapojený obsah; dnes jsou zdroje čtyři a všechny z něj vycházejí
rozklad presenteru do traitů app/UI/Front/Blog/Presenters/Traits/ — Inits/, Lists/, Details/ jeden z mála presenterů, který má rozdělení hotové podle konvence

Podrobně Elasticsearch a Konvence kódu.

Nekopírujte vzory z knihy návštěv

Je to nejstarší část modulu a část jejího kódu je ještě v podobě předchozí generace. Vypadá to jako platný vzor, protože je to týž modul — ale není.

Kategorie a štítky#

Kategorie tvoří strom a promítají se do adres; štítky jsou plochý seznam a jen označují.

Štítek je levnější než kategorie

Kategorie mění strukturu i URL — a s tím i překlady, sitemapy a odkazy zvenčí. Když stačí označit, použijte štítek.

Kategorie blogu mají GLOBÁLNĚ unikátní href

cms_mod_blog_category_texts a cms_mod_blog_article_category_texts mají UNIQUE(href) — bez jazyka. Ostatní kategoriové tabulky v systému mají správně UNIQUE(href, language_id). Dvě kategorie v různých jazycích tu proto nemohou mít stejnou adresu, a čeština se slovenštinou sdílí spoustu slov (doprava, zahrada). Projeví se to až při zakládání druhého jazyka jako Duplicate entry 'doprava' for key 'ind_href' — před vložením se proto musí kontrolovat kolize bez filtru na jazyk.

Odkazy kategorií a regiony#

Články, novinky a události jsou tři samostatné stránky (id 50, 52, 54) se třemi presentery, kategorie je ale sdílená. Cíl odkazu na kategorii proto určuje app/UI/Front/Blog/Components/Traits/BlogListTargetTrait.php:

Metoda Co dělá
blogListTarget($type) podle typu položky (article, news, event) vrátí cíl a id stránky
blogListTargetForPage($presenter) totéž podle aktuální stránky (52/53 = novinky, 54/55 = události, jinak články)
blogListParams($slug, $categoryId, $regionId) parametry odkazu; s vybraným regionem je segment kategorie vždy výslovný (- = žádná), jinak by se region posunul na místo kategorie
blogRegionLinks($presenter, $type, $items, $dispatcher) odkazy /<výpis>/-/<region> na výpis téhož typu filtrovaný regionem položky

Parametr kategorie je categoryId (maska rout 101/102/104), ne category. Dřív odkazy na kategorii vedly vždy na články (a vyráběly /clanky/o-1?category=109), a číselná adresa /udalosti/1 padala na TypeError v CategoryTrait. Teď:

  • EventListRouteFactory registruje překlad pod klíčem categoryId, NewsListRouteFactory ho má odkomentovaný,
  • CategoryTrait přetypuje id na int; neznámé číselné id kanonizuje 301 na slug,
  • karty, detail novinky, CategoryList, CategoryChildrenList a drobečky volají BlogListTargetTrait.

Zbývají odkazy bez categoryId

Několik míst, které nikdo nevkládá (Article/parts/*, BreadcrumbComponent/CategoryTrailTrait vede vždy na články) a starý CategoryList Blogu s tím ještě počítají. Přepínač modulů v template1/…/shared/main.latte přidává k odkazu na Blog ?category=<id>, které Blog ignoruje.

Region u článků a novinek#

Region měla dřív jen Event (sloupec, Picker v administraci, filtr včetně podstromu, routa /udalosti/<kat>/<region>/o-<page>). Article a News ho získaly stejným vzorem.

Vrstva Co
SQL sql/2026-10-01_01_blog_article_news_region.sql: sloupec cms_system_regions_region_id u článků a novinek, index a cizí klíč ON DELETE SET NULL (rollback …_91_). Existující záznamy zůstávají bez regionu
Entity ve čtyřech vrstvách (Article, News) — asociace region + skalár regionId
Administrace RegionPickerTrait v presenterech, skryté pole regionId plní Picker; v Article/News/EventFormTrait se při uložení nastavuje setRegion() i setRegionId()
Front Blog\RegionTrait (initCurrentRegion(), prepareRegionFilter()), filtr region.lft ≥ … ∧ region.rgt ≤ … včetně podstromu
Routa překlad regionId v ArticleListRouteFactory a NewsListRouteFactory (masky segment měly)
Výpis include region.translations ve výpisech, detailech a v ArticleListProvider, TaggedArticleList, NewsComponent

Region nešel z formuláře zrušit, dokud se nenastavilo i setRegionId()

extract posílá do Api starý skalár regionId načtené entity. Zrušení výběru (setRegion(null)) bez setRegionId(null) se proto při uložení vrátilo. Nový formulář s regionem musí nastavit obojí.

Záznam bez regionu se při zvoleném regionu nezobrazí

Je to záměr (stejné jako u kategorie); filtr je shora dolů. Neznámý slug regionu vrací výpis bez filtru, stejně jako u kategorie.

Název regionu u položky: řádkový (Style_1) a tabulkový (Style_3) výpis a detaily (info-row) ho ukazují s odkazem; karetní styly se štítky (Style_2/4/5/6) ho záměrně vynechávají, řádek štítků se ořezává. ArticleDocumentSource region do Elasticsearch neindexuje — filtr jde přes databázi, stejně jako u inzerce.

Vícejazyčnost#

Texty jsou v podřízené entitě, jedna na jazyk (ArticleText, NewsText, …).

Prázdný překlad přebije náhradní jazyk

Uložený prázdný řetězec je platná hodnota, takže vyhraje nad fallbackem a prvek vyjde bez názvu. Buď přeložit, nebo řádek vůbec nezakládat — podrobně Překlady.

Textová entita má odvozenou identitu

Nemá vlastní klíč — je složený z rodiče a jazyka. Chybějící use ORM v takové entitě se přitom projeví tiše: mapování se nenačte a entita se chová, jako by sloupce neměla.

Komentáře, hodnocení a ankety#

Neřeší je blog sám — jsou to sdílené komponenty z modulu Base (Comment, CommentThread, Rating, RatingThread, Poll).

Komentáře mají články, galerie, kniha návštěv a akce (události). Akce má typ vlákna ThreadType::BlogEvent ('blog_event'): Api EventManager jí vlákno komentářů založí při uložení (CommentThreadCreatorTrait) a starší akci bez vlákna ho doplní při jejím dalším uložení; běžná úprava akce s vláknem nic nezakládá. Smazání akce smaže i její vlákno (OwnedThreadDeleteTrait).

Změna sdílené komponenty zasáhne tři další moduly

Komentáře i hodnocení používá vedle blogu i e-shop (produkty), inzerce a katalog firem. Oprava „jen pro článek“ se projeví všude — a otestuje ji jen ten, kdo si na to vzpomene.

Uživatelský text je XSS past

addSimpleEditor() sám nic nesanitizuje. Rich text se musí purifikovat v Api beforeSave, prostý text se vypisuje přes |breaklines. Podrobně Formuláře.

Komponenta hodnocení na stránce#

Rating (app/UI/Front/Base/Components/RatingComponent/) hodnotí cokoli, co drží skalární ratingThreadId; vlákno musí existovat. Vzhled určuje styl, buď setStyle() v createComponentRating(), nebo varianta v šabloně ({control rating:review}, {control frontBaseRating:number $id}).

Styl Varianta Co vykreslí
STYLE_CLASSIC (výchozí) {control rating} interaktivní hvězdy, jeden klik = jedno hodnocení přes AJAX
STYLE_STATIC :static průměr jen ke čtení
STYLE_REVIEW :review souhrnné pruhy, tlačítko recenze a seznam recenzí
STYLE_SUMMARY :summary souhrnné pruhy bez recenzí
STYLE_REVIEWS :reviews jen seznam recenzí
STYLE_REVIEWS_DETAILED :reviewsDetailed souhrn, filtr podle hvězd, řazení a seznam; souhrn vypne setShowSummary(false)
STYLE_NUMBER :number jen číslo průměru, bez HTML
STYLE_THUMBS :thumbs palec nahoru (hodnota 5) a dolů (hodnota 1); setThumbs(['up']) nechá jen „líbí se“

Kdo smí hodnotit, rozhoduje volající, ne komponenta:

Nastavení Co řídí
setAllowAnonymous() smí hodnotit nepřihlášený (článek ano, profil a produkt ne)
setAllowVoting() smí tenhle návštěvník recenzi napsat (e-shop: jen kupující sady, CheckoutManager::setBuyers())
setVerifiedBuyersResolver() odznak „Ověřený nákup“ u cizích recenzí

Resolver je funkce list<int> → list<int>: komponenta ji zavolá jednou se všemi autory na stránce a dostane zpět ty ověřené. Komponenta je sdílená a o objednávkách nic neví; bez resolveru (blog, profil) odznak není. Právo napsat recenzi se tímhle neřeší.

Styl reviewsDetailed už souhrn obsahuje

Na stránce s STYLE_REVIEWS_DETAILED nevkládej ještě STYLE_SUMMARY, jinak se tatáž čísla vypíšou dvakrát.

Souhrn hodnocení#

Výpisy nečtou jednotlivá hodnocení, ale souhrn na vláknu (RatingThread): rating_count, rating_avg a count_1 až count_5, počítané jen z hodnocení s active = 1.

Kdo souhrn přepočítá Kdy
RatingManager::afterSave() → recalcThread() po každém uložení hodnocení, jen jeho vlákno
RatingManager::afterDelete() po smazání hodnocení (i dávkovém delete([…])), vlákna smazaných hodnocení, ve stejné transakci
RatingManager::deleteAll() po hromadném mazání podle filtrů, dotčená vlákna
RatingManager::updateColumns() po změně approved, deleted, value nebo threadId, vlákna před i po změně; jiné sloupce (např. userName) přepočet nespouští
cron /cron/base/rating/recalc → recalcAllThreads() denně; jeden hromadný UPDATE přes všechna vlákna, pojistka proti rozjetí; vlákno bez aktivních hodnocení vynuluje na 0

Raw SQL souhrn nepřepočítá

Mazání i zápis sloupců přes manager souhrn přepočítají hned. Když hodnocení měníš vlastním SQL mimo manager, zavolej po něm RatingManager::recalcThread($threadId), jinak souhrn opraví až noční cron.

Jeden hlas na návštěvníka#

Opakované hodnocení a hlas o užitečnosti recenze hlídá DuplicateVoterGuardTrait::voterFilters() (RatingManager::hasRated(), RatingVoteManager::hasVoted()):

Kdo hlasuje Podle čeho se hledá předchozí hlas
přihlášený userId, IP se ignoruje (jeden účet = jeden hlas z jakéhokoli zařízení)
anonym userId IS NULL AND ip = …
anonym bez IP nelze rozhodnout, metoda vrátí null

Unikátní index anonyma neohlídá

Index UNIQUE(…, user_id) dvě hodnoty NULL za shodné nepovažuje, takže anonym by bez aplikační kontroly mohl hlasovat znovu a znovu. Nová hlasovací entita potřebuje stejnou kontrolu v Api a null z voterFilters() musí číst jako „nelze ověřit“, ne „nehlasoval“.

Schválení nového komentáře a hodnocení#

Zda se nový komentář nebo hodnocení zveřejní hned, rozhoduje beforeSave() Api manageru podle nastavení modulu Base (modul 2). V administraci jsou v Nastavení webu → sekce Komentáře, hodnocení a newsletter.

Klíč Platí pro Chybí-li řádek
comment.useAutoApproval každý nový komentář (CommentManager) 1 = schváleno hned
rating.useAutoApprovalStars hodnocení jen hvězdičkami (RatingManager) 0 = čeká na schválení
rating.useAutoApprovalReviews slovní recenze — text, klady, zápory (Rating::isReview()) 0 = čeká na schválení

Front komponenta hodnocení smí oba klíče hodnocení přebít pro jednu instanci (setOverrideRatingUseAutoApprovalStars(), setOverrideRatingUseAutoApprovalReview()); jde to do $meta pod stejným klíčem a $meta má přednost před databází. Úpravy existujícího záznamu se stav schválení netýká — jinak by každé uložení z administrace přepsalo ruční schválení, odschválení i měkké smazání.

Výchozí hodnoty komentářů a hodnocení jsou opačné

Komentáře se odjakživa zveřejňovaly hned, proto chybějící řádek znamená 1. Kdo by výchozí hodnotu sjednotil na 0, zapne tiše moderaci všem modulům a komentáře po odeslání zmizí z webu. Moderovaný režim se zapíná uložením 0 do řádku.

Klíč recenzí je v množném čísle

rating.useAutoApprovalReviews, stejně jako …Stars. Čtení …Review by řádek nikdy nenašlo a recenze by šly vždy do moderace, ať je přepínač nastavený jakkoli.

Brány při vložení#

Kontroly běží v beforeSave() Api manageru, protože jen tudy projde každý insert — front, administrace přes ApiBridge i externí :Api:Base:*:save. Formulář může mít vlastní hlášku navíc, ale brána v manageru je ta, která platí.

Manager Kontrola Odpověď
CommentManager kvóta zakládání obsahu (base-comment) RATE_LIMIT_EXCEEDED
CommentManager rodič odpovědi patří do téhož vlákna (rejectForeignParent(), jeden dotaz na dvojici id + threadId) INVALID_VALUE, errorCode: commentParentThreadMismatch
RatingManager vlákno hodnocení existuje INVALID_DATA
RatingManager přihlášený autor není majitelem hodnoceného objektu INVALID_DATA, errorCode: selfRating
RatingManager tentýž návštěvník tu ještě nehodnotil (hasRated(), viz výše) DB_DUPLICATE_ENTRY, errorCode: alreadyRated

Komentář s rodičem z cizího vlákna ze stromu zmizí

threadId a parentId jsou dva nezávislé skaláry. Strom se načítá jen podle threadId, takže rodič v načtené množině chybí a odpověď se nikde nevykreslí, přestože v databázi je. Nové UI, které odpovědi zakládá, proto nesmí bránu v manageru obejít.

Odpověď na hodnocení: autor a hodnocený#

Hodnocení má dva různé lidi, kteří s ním smějí něco dělat, a každý má vlastní akci.

Kdo Odkud se zjistí Smí Akce a role
autor recenze Rating::$userId (RatingManager::isOwnedBy()) upravit svou recenzi :Api:Base:Rating:save, role owner
majitel hodnoceného objektu z DB: hodnocení → vlákno → objekt → majitel (RatingManager::findRespondableOwner()) napsat nebo smazat odpověď (response) :Api:Base:Rating:saveReply, role receiver

Odpověď zapisuje jen RatingManager::saveReply(); responseDatetime dosazuje server a se smazáním odpovědi ho nuluje. Front se předem ptá internalActionCanRespondTo(), odpověď je v data.canRespond a jiný tvar se čte jako false.

Generický save je obalený (RatingPresenter::internalActionSave()): když payload mění odpověď proti stavu v DB (RatingManager::payloadChangesReply()), musí volající projít navíc guardem saveReply. Bez toho by si autor přes save dopsal „odpověď prodejce“ do vlastní recenze.

Payload save payloadChangesReply()
bez klíče response false
nové hodnocení s prázdnou odpovědí (front posílá response => null) false, bez dotazu do DB
nové hodnocení s odpovědí true
úprava, normalizovaná odpověď se liší od uložené (i smazání) true
neznámé id true — rozhodne guard

Majitel je dnes odvoditelný jen u profilu uživatele (ThreadType::SystemUser, vazba User::$ratingThreadId). U ostatních typů (článek, produkt, inzerát katalogu…) vrací findRespondableOwner() null a odpovědět nesmí nikdo kromě správce.

Postup: povolit odpověď u dalšího typu hodnoceného objektu

  1. Rozšiř RatingManager::findThreadOwner() o větev pro nový ThreadType: najdi objekt podle jeho sloupce ratingThreadId a vrať jeho majitele (User).
  2. Nic jiného neměň. canRespondTo(), guard saveReply i obal save se ptají téže metody, takže pravidlo platí všude naráz. Stejná metoda zároveň zakáže majiteli hodnotit vlastní objekt (selfRating).
  3. Ověř odpověď majitele, zamítnutí autora recenze a zamítnutí cizího uživatele (RatingSaveReplyReceiverGateTest je vzor).

Nezaměňuj isOwnedBy() a canRespondTo()

Naivní rating.userId === jwt.userId u odpovědi dá přesně opačný výsledek: odpovídat by mohl autor recenze sám sobě a hodnocený ne.

saveReply() nevolej mimo guardovanou akci

Manager vlastnictví znovu neověřuje — rozhodl o něm guard v presenteru, který jediný zná identitu z JWT. Volání odjinud autorizaci obejde.

Bez řádku oprávnění saveReply neodpoví nikdo

Chybí-li klíč :Api:Base:Rating:saveReply v databázi, externí cesta zamítne všechny včetně správce. Nouzový režim internalMode: off front pustí, takže chyba se ukáže až po přepnutí do enforce.

Hlas o užitečnosti a nahlášení recenze#

„Byla tahle recenze užitečná?“ a „Nahlásit“ zapisuje RatingVoteManager do jedné tabulky (RatingVote, rozlišené kind). Volají se jen interní akce RatingPresenter — vote, report a myVotes (jak návštěvník hlasoval u recenzí na stránce). Nemají action*() a vlastní CRUD nad RatingVote záměrně neexistuje: generický save by přijal cizí userId i libovolnou ip a obešel kontrolu duplicit.

Situace Odpověď
hodnocení neexistuje nebo není aktivní (neschválené, smazané) ITEM_NOT_FOUND
autor hlasuje o vlastní recenzi PERMISSION_DENIED
anonym a rating.allowAnonymousReviewVote není 1 (chybí-li řádek, je to „ne“) PERMISSION_DENIED
opakovaný hlas nebo nahlášení DB_DUPLICATE_ENTRY — odmítá se, nepřepisuje
úspěch uloží se řádek a počet na hodnocení se zvýší v DB (incrementCount()), ne přepíše

Nahlášení recenzi neskryje ani nesmaže; jen zvedne reports a správce si je vyfiltruje. userId a ip musí volající dosadit ze serverové identity (front: session a getRemoteAddress()), nikdy z payloadu.

Unikátní klíč nad (rating_id, ip) nepřidávej

Přihlášené jistí UNIQUE uq_rating_user i proti souběhu; anonymy hlídá aplikace podle IP. Unikátní klíč nad IP by zablokoval dva přihlášené uživatele z jedné sítě.

Hlas v anketě#

PollPresenter::internalActionRegisterVote() (externě actionRegisterVote()) hydratuje celý PollVote z payloadu, ale ip a insertDatetime vždy přepíše hodnotou ze serveru ještě před hydratací. IP je součást primárního klíče a jediná ochrana proti opakovanému hlasování.

#[ExternalReadonly] ve vlastní akci nic nehlídá

Ořez atributů běží jen v SaveTrait::saveEntityData() (generický save). Vlastní akce, která hydratuje entitu z payloadu, musí serverem počítaná pole přepsat sama — jinak si volající pošle voteData[ip] a hlasuje znovu a znovu.

Komentáře a query cache#

Čtení komentářů nezávisí na volajícím, takže CommentPresenter má query cache zapnutou jako ostatní presentery a výpisy komentářů nechodí pokaždé do databáze.

Soukromou komunikaci nedávej mezi komentáře

Výsledek čtení by začal záviset na volajícím a wire-payload cache je na uživateli nezávislá: správce by ji nahřál a host by četl cizí konverzaci, nebo naopak. Muselo by se vrátit isQueryCacheEnabled(): false, filterReadPayload() a filtry v managerech i presenterech. Soukromá komunikace dostává vlastní tabulku (vzor: komentáře práce v modulu Moje firma, Soukromé zprávy).

Komentáře a hodnocení v editaci objektu#

Tab s komentáři nebo hodnoceními v editaci článku, produktu či inzerátu vykreslují sdílené admin komponenty CommentThread a RatingThread. Komponenta je klíčovaná id vlákna, takže ji vloží kterýkoli editační formulář, který vlákno má. Přidání a úprava běží ve sdíleném modálním okně administrace, mazání je měkké (příznak deleted).

Vlákno jde do formuláře ve skrytém poli threadId. To pole si ale klient může přepsat, takže je jen ozvěna: o tom, ke kterému vláknu se zapisuje, rozhoduje serverový kontext, který hostitel komponentě předá při vytvoření.

Kde Co se kontroluje Když nesouhlasí
signály add…!, edit…!, toggleDeleted! threadId z URL = kontext hostitele 403
odeslání formuláře skryté threadId = kontext; rodič odpovědi a upravovaný záznam se hledají filtrem id + threadId nic se neuloží, modál ukáže chybu
bez kontextu nic komponenta nic neuloží ani neotevře modál

Postup: přidávám komentáře nebo hodnocení do další editace

  1. Injektuj továrnu (CommentThreadFactory, RatingThreadFactory) konstruktorem presenteru nebo editační komponenty.
  2. V továrně komponenty nastav kontext — callback, který vrátí vlákno právě editované entity:

    protected function createComponentCommentThread(): CommentThread
    {
        return $this->commentThreadFactory->create()
            ->setThreadContext(fn(): ?int => $this->editedArticle?->getCommentThreadId());
    }
    

    Callback se volá až při signálu nebo odeslání, kdy action*() hostitele entitu už načetl. 3. Do šablony tabu dej {control commentThread $commentThreadId} ({control ratingThread $ratingThreadId}); id vlákna předej z render*(). 4. Ověř práva: komponenty samy hlídají :Admin:Base:Comment:edit / :del a :Admin:Base:Rating:edit / :del, i při ručně sestavené URL.

Bez setThreadContext() komponenta tiše nefunguje

Výpis se vykreslí, ale tlačítka Přidat a Upravit modál neotevřou (signál skončí 403) a uložení nic nezapíše. Kontext je pojistka proti tomu, aby podvržené threadId zapsalo komentář nebo hodnocení k cizímu objektu.

Moderace napříč sekcemi. Vedle tabů jsou dva centrální výpisy přes celé tabulky: CommentPresenter a RatingPresenter (detail vlákna CommentThreadPresenter, RatingThreadPresenter). Sekce (e-shop, blog…) je uložená na vláknu jako type, ne na řádku komentáře. Asociaci na vlákno namapovat nejde, protože týž sloupec už mapuje skalár threadId a Doctrine druhé zapisovatelné mapování odmítne. Výpis proto načte vlákna zvlášť a spáruje je mapou threadId => type; odkaz z menu modulu je tentýž výpis s předvyplněným type. Konkrétní článek nebo produkt výpis neukáže: vazbu drží objekt (commentThreadId, ratingThreadId), ne vlákno.

Mapa sekcí načítá všechna vlákna

Vlákno vzniká ke každému komentovatelnému objektu, takže u velkého katalogu výpis zpomalí. Pak je potřeba dotahovat typy jen pro threadId na aktuální stránce. Vlákna bez type (sloupec je nullable) mají sekci prázdnou a filtr sekce je nenajde.

Výpisy článků na frontu#

Krabice článků na hlavní straně a v postranních sloupcích jsou tenké obálky nad ArticleListProvider. Obálka určí jen řazení (v šabloně jako $orderBy), případně filtr navíc, a předá je dál; načtení i vykreslení dělá provider.

Komponenta Řazení Filtr navíc Výchozí styl, počet
LastArticleList published ↓ — Row, 5
MostReadArticleList views ↓ — Row, 5
MostCommentedArticleList commentThread.commentCount ↓, published ↓ — Line, 3
TopRatedArticleList ratingThread.ratingAvg ↓, ratingThread.ratingCount ↓ — Row, 5
MainArticleList published ↓ main = 1 Editorial, 1
TaggedArticleList id ↓ tags.id IN (…) Cards, 5; vlastní načítání, ne přes provider

Počty komentářů a hodnocení se nepočítají agregací: drží je sdílená vlákna (cms_mod_base_comment_threads.comment_count, cms_mod_base_rating_threads.rating_avg a rating_count), na která článek ukazuje nullable FK. Řadí se přes asociaci, kterou JoinHelper připojí LEFT JOINem, takže článek bez vlákna z výpisu nevypadne, jen propadne na konec. Druhý klíč u hodnocení je záměr: bez něj by článek s jedinou pětkou předběhl článek se stovkou hodnocení a průměrem 4,9.

Provider k filtrům volajícího vždy přidá activated = 1 a published <= NOW. Přiřazuje je až po filtrech volajícího pod klíči activated a published, takže je volající nepřebije ani stejným klíčem. Filtrové objekty se staví v PHP komponenty, ne v Latte (MainArticleList předá $filters šabloně hotové).

Postup: přidat krabici článků s vlastním řazením#

  1. Zkopíruj nejbližší obálku (LastArticleList pro prosté řazení, MainArticleList, když potřebuješ filtr).
  2. V šabloně obálky nastav $orderBy; řazení přes vlákno piš tečkovou cestou (ratingThread.ratingAvg), ne skalárem.
  3. Filtr navíc postav v render() a pošli ho jako $filters s klíčem, který se nekříží s activated a published.
  4. Zkontroluj include v provideru: styl Line vypisuje počet komentářů z commentThread; bez něj by neměl co vypsat.

Bez označeného hlavního článku je titulní blok prázdný

MainArticleList při nulovém počtu článků s příznakem main vykreslí hlášku „žádné články“ a nedosadí nejnovější článek. Je to záměr: redakce má poznat, že titulek nikde nevybrala. Příznak smí mít víc článků, vybírá řazení a limit volajícího.

TaggedArticleList s prázdnými štítky vypíše všechny články

Prázdné $tagIds filtr na štítky vůbec nepřidá a komponenta se chová jako obyčejný výpis nejnovějších (id ↓). Štítek bez článků naopak dá prázdný výpis s běžnou hláškou.

showOnlyCategoryIds omezí výpis na kategorie

Všechny obálky i ArticleListProvider::render() ho předávají do InitRenderTrait::initRender(), který z něj postaví filtr categories.id IN (...) (stejný tvar jako tags.id v TaggedArticleList). Článek stačí mít v jedné z kategorií. Prázdný seznam filtr nepřidá a výpis ukáže všechny kategorie. Klíč categoryId v $filters tento parametr přepíše.

Galerie na hlavní straně#

Galerii na hlavní stranu kreslí LastGallery: vybere nejnovější aktivní a publikovanou galerii (nebo zadané $galleryId) a vykreslení předá frontSystemGallery. Galerie s only_for_logged přeskakuje, i když je zadaná id, protože na veřejné straně by na ni půlka návštěvníků nedosáhla. Fotky nad $maxAssets sečte do „+ N“.

Karusel#

Karusel z administrace (Blog → Karusely) kreslí na webu komponenta frontBlogCarousel (app/UI/Front/Blog/Components/CarouselComponent/):

{control frontBlogCarousel 1}      {* karusel 1, jeden snímek naráz *}
{control frontBlogCarousel 1, 3}   {* tři viditelné snímky *}
Pravidlo Jak
který karusel jen aktivní (activated = 1) se zveřejněním prázdným nebo v minulosti
snímky HTML kód snímku v jazyce stránky; snímek bez kódu v tom jazyce se přeskočí, bez snímků se nevykreslí nic
kostra sdílená .list--carousel (jako bleskovky); šipky, tečky a posun dělá tentýž skript, počet viditelných nese --carousel-cols
šipky a tečky jen když je snímků víc než viditelných ($perPage)
oprávnění čtení :Api:Blog:Carousel:getAll má host i přihlášený, stejně jako bleskovky

Kód snímku se vypisuje bez escapování

Snímek je HTML z administrace a šablona ho tiskne |noescape. Zápis snímků proto nesmí dostat role mimo administraci; nová cesta zápisu z frontu nebo od externího klienta by potřebovala purifikaci v Api beforeSave().

Obrázky obsahu#

Obrázky neprocházejí objektovým grafem. Administrace je posílá v $meta a Api manager je zpracuje v afterSave(): uloží Asset s variantami, zapíše vazbu a obrázky, které z formuláře vypadly, uvolní (viz Varianty obrázků).

Entita Klíče v $meta Chybějící klíč
Article, Category, Event, FlashMessage (jeden obrázek) image = {name, data} (base64) → nový obrázek; imageDelete = true bez image → cizí klíč asset se vynuluje obrázek zůstane
Gallery images = seznam řádků v pořadí galerie; řádek nese image (nový) nebo assetId (ponechaný) a texts[<langId>] = {title, description} bez klíče images zůstane galerie, jak je
Intro (obrázek za každý jazyk) imageLanguages = id jazyků formuláře; pro každý image_<langId> (nový), imageDelete_<langId>, imageCurrentId_<langId> (ponechaný) bez imageLanguages zůstanou vazby, jak jsou

Galerie a úvodní panel zapisují vazby přes updateColumn('assetRels', …), tedy úplným nahrazením. Co v $meta není, z galerie zmizí. Externí klient smí poslat jen images galerie (vnořené v data, $graphDataKeys); ostatní klíče jsou jen v $meta, které externí cesta zahazuje.

Postup: vlastní zápis obrázků galerie nebo panelu

  1. Načti stávající obrázky a pošli je všechny: u galerie řádky s assetId, u panelu imageCurrentId_<langId> pro každý jazyk z imageLanguages.
  2. U ponechaného obrázku galerie pošli i texts, jinak přijde o popisky.
  3. Pořadí řádků galerie je pořadí obrázků; prázdný řádek a opakovaný obrázek se přeskočí.
  4. Ověř v DB vazby (cms_mod_blog_gallery_image_relations, cms_mod_blog_intro_image_relations) i to, že vyřazené obrázky zmizely z cms_system_images.

Jazyk v imageLanguages bez obrázku přijde o obrázek

Když pro jazyk nepřijde nový obrázek ani imageCurrentId_<langId>, vazba se nezapíše a obrázek se uvolní, i se soubory. Stejně dopadne obrázek galerie, který v images chybí.

Popisky obrázku galerie se přepisují celé

Texty se zapíšou smazáním a vložením (updateColumn('translations', …)) u každého řádku, i u ponechaného. Řádek bez texts popisky ve všech jazycích smaže. Jazyk s prázdným titulkem i popisem se vynechá.

Naplánované úlohy#

Endpoint Co dělá Doporučený interval
/cron/blog/elastic/sync průběžná synchronizace indexu à 10 minut
/cron/blog/elastic/reindex úplný reindex denně v noci
/cron/blog/sitemap/{article,event,news} sitemapy obsahu denně v noci
/cron/blog/sitemap/{article,event,news}-category sitemapy kategorií denně v noci

Kam sáhnout#

Chci Kde
vzor zdroje pro vyhledávání app/UI/Api/Blog/Models/Search/ArticleDocumentSource.php
vzor rozkladu presenteru app/UI/Front/Blog/Presenters/Traits/
managery obsahu app/UI/Api/Blog/Models/Managers/
sdílené komentáře a hodnocení app/UI/Api/Base/Models/Entities/
schválení, brány vložení, odpověď na hodnocení app/UI/Api/Base/Models/Managers/{Comment,Rating}Manager.php, app/UI/Api/Base/Presenters/RatingPresenter.php
hlas o užitečnosti a nahlášení app/UI/Api/Base/Models/Managers/RatingVoteManager.php
cron úlohy app/UI/Cron/Blog/Presenters/
cíl odkazů kategorií a regionů app/UI/Front/Blog/Components/Traits/BlogListTargetTrait.php
region ve frontu app/UI/Front/Blog/Presenters/Traits/Inits/RegionTrait.php
krabice článků a galerie na hlavní straně app/UI/Front/Blog/Components/{ArticleListProvider,MainArticleList,TaggedArticleList,LastGallery}Component/

Navazující kapitoly: Elasticsearch · Diskuze · Překlady · Konvence kódu

Nastavení cookies