Blog#
The site's editorial content — articles, news, events, galleries, carousels, a guestbook
and a contact form. The code lives in app/UI/Api/Blog/, the front end in
app/UI/Front/Blog/.
The blog is also the system's reference module: it was the first content wired into Elasticsearch and its front presenter is the model for splitting into traits. Anyone looking for how something is done properly in this CMS starts here.
What the module is made of#
| Group | Entities | Note |
|---|---|---|
| content | Article, News, Event, Intro plus their *Text |
each has its own category and listing |
| media | Gallery + GalleryAssetRel, Carousel + CarouselItem |
|
| taxonomy | Category (a tree), Tag (a flat list) |
|
| interaction | Guestbook + GuestbookMessage, ContactMessage, ArticleDiscussionMessage |
|
| extras | FlashMessage, ArticlePollRel, ArticleAdvertRel |
links to polls and advertising |
Two models to copy from#
| Model | File | Why this one |
|---|---|---|
| an Elasticsearch source | app/UI/Api/Blog/Models/Search/ArticleDocumentSource.php |
the article was the first content wired in; there are four sources today and all derive from it |
| splitting a presenter into traits | app/UI/Front/Blog/Presenters/Traits/ — Inits/, Lists/, Details/ |
one of the few presenters where the split follows the convention |
In detail: Elasticsearch and Code conventions.
Do not copy patterns from the guestbook
It is the oldest part of the module and some of its code is still in the previous generation's shape. It looks like a valid model because it is the same module — but it is not.
Categories and tags#
Categories form a tree and are reflected in URLs; tags are a flat list and only label.
A tag is cheaper than a category
A category changes the structure and the URLs — and with them the translations, the sitemaps and inbound links. When labelling is enough, use a tag.
Blog categories have a GLOBALLY unique href
cms_mod_blog_category_texts and cms_mod_blog_article_category_texts carry
UNIQUE(href) — without the language. The other category tables in the system
correctly have UNIQUE(href, language_id). Two categories in different languages
therefore cannot share an address here, and Czech and Slovak share plenty of words
(doprava, zahrada). It surfaces only when the second language is created, as
Duplicate entry 'doprava' for key 'ind_href' — a collision check before the insert
therefore has to run without a language filter.
Category links and regions#
Articles, news and events are three separate pages (ids 50, 52, 54) with three presenters,
but the category is shared. The target of a category link is therefore decided by
app/UI/Front/Blog/Components/Traits/BlogListTargetTrait.php:
| Method | What it does |
|---|---|
blogListTarget($type) |
by item type (article, news, event) returns the target and the page id |
blogListTargetForPage($presenter) |
the same by the current page (52/53 = news, 54/55 = events, otherwise articles) |
blogListParams($slug, $categoryId, $regionId) |
the link parameters; with a region selected the category segment is always explicit (- = none), otherwise the region would shift into the category's place |
blogRegionLinks($presenter, $type, $items, $dispatcher) |
links /<listing>/-/<region> to a listing of the same type filtered by the item's region |
The category parameter is categoryId (route mask 101/102/104), not category. Before,
category links always led to articles (and produced /clanky/o-1?category=109), and the
numeric address /udalosti/1 fell on a TypeError in CategoryTrait. Now:
EventListRouteFactoryregisters the translation under the keycategoryId,NewsListRouteFactoryhas it uncommented,CategoryTraitcasts the id toint; an unknown numeric id is canonicalised by a 301 to the slug,- cards, the news detail,
CategoryList,CategoryChildrenListand breadcrumbs callBlogListTargetTrait.
Links without categoryId remain
A few places nobody includes (Article/parts/*,
BreadcrumbComponent/CategoryTrailTrait always leads to articles) and the old blog
CategoryList still count on it. The module switcher in template1/…/shared/main.latte
adds ?category=<id> to the Blog link, which Blog ignores.
Region on articles and news#
Only Event used to have a region (a column, a Picker in the administration, a filter
including the subtree, the route /udalosti/<cat>/<region>/o-<page>). Article and News
got it by the same pattern.
| Layer | What |
|---|---|
| SQL | sql/2026-10-01_01_blog_article_news_region.sql: the column cms_system_regions_region_id on articles and news, an index and a foreign key ON DELETE SET NULL (rollback …_91_). Existing records stay without a region |
| Entities | in four layers (Article, News) — an association region + a scalar regionId |
| Administration | RegionPickerTrait in the presenters, the hidden regionId is filled by the Picker; in Article/News/EventFormTrait both setRegion() and setRegionId() are set on save |
| Front | Blog\RegionTrait (initCurrentRegion(), prepareRegionFilter()), the filter region.lft ≥ … ∧ region.rgt ≤ … including the subtree |
| Route | the regionId translation in ArticleListRouteFactory and NewsListRouteFactory (the masks already had the segment) |
| Listing | include region.translations in listings, details and in ArticleListProvider, TaggedArticleList, NewsComponent |
The region could not be cleared from the form until setRegionId() was set too
extract sends the old scalar regionId of the loaded entity to the Api. Clearing the
choice (setRegion(null)) without setRegionId(null) therefore came back on save. A new
form with a region must set both.
A record without a region is not shown when a region is selected
It is deliberate (the same as for the category); the filter is top-down. An unknown region slug returns the listing without a filter, like for a category.
The region name on an item: the row (Style_1) and table (Style_3) listings and the details
(info-row) show it with a link; the card styles with badges (Style_2/4/5/6) deliberately
leave it out, the badge row gets trimmed. ArticleDocumentSource does not index the region
into Elasticsearch — the filter goes through the database, as with classifieds.
Multilingual content#
The texts live in a child entity, one per language (ArticleText, NewsText, …).
An empty translation beats the fallback
A stored empty string is a valid value, so it wins over the fallback and the element comes out with no name. Either translate it or do not create the row at all — in detail Translations.
A text entity has a derived identity
It has no key of its own — it is composed of the parent and the language. A missing
use ORM in such an entity surfaces silently: the mapping is not loaded and the
entity behaves as if it had no columns.
Comments, ratings and polls#
The blog does not handle them itself — they are shared components from the Base module
(Comment, CommentThread, Rating, RatingThread, Poll).
Comments exist for articles, galleries, the guestbook and events. An event has the thread
type ThreadType::BlogEvent ('blog_event'): the Api EventManager creates its comment thread
on save (CommentThreadCreatorTrait), and an older event without a thread gets one on its next
save; an ordinary edit of an event that has a thread creates nothing. Deleting an event deletes
its thread too (OwnedThreadDeleteTrait).
Changing a shared component affects three other modules
Comments and ratings are used, besides the blog, by the e-shop (products), classifieds and the company catalogue. A fix "just for articles" shows up everywhere — and only gets tested by whoever remembers to.
User-entered text is an XSS trap
addSimpleEditor() sanitises nothing by itself. Rich text has to be purified in the
Api beforeSave, plain text is printed through |breaklines. In detail:
Forms.
The rating component on a page#
Rating (app/UI/Front/Base/Components/RatingComponent/) rates anything that holds a scalar
ratingThreadId; the thread must exist. The look is set by the style, either with setStyle()
in createComponentRating() or with a variant in the template ({control rating:review},
{control frontBaseRating:number $id}).
| Style | Variant | What it renders |
|---|---|---|
STYLE_CLASSIC (default) |
{control rating} |
interactive stars, one click = one rating via AJAX |
STYLE_STATIC |
:static |
read-only average |
STYLE_REVIEW |
:review |
summary bars, review button and review list |
STYLE_SUMMARY |
:summary |
summary bars without reviews |
STYLE_REVIEWS |
:reviews |
review list only |
STYLE_REVIEWS_DETAILED |
:reviewsDetailed |
summary, star filter, sorting and the list; setShowSummary(false) turns the summary off |
STYLE_NUMBER |
:number |
the average as a plain number, no HTML |
STYLE_THUMBS |
:thumbs |
thumb up (value 5) and down (value 1); setThumbs(['up']) keeps only "like" |
Who may rate is decided by the caller, not by the component:
| Setting | What it controls |
|---|---|
setAllowAnonymous() |
whether a guest may rate (article yes, profile and product no) |
setAllowVoting() |
whether this visitor may write a review (e-shop: buyers of the set only, CheckoutManager::setBuyers()) |
setVerifiedBuyersResolver() |
the "Verified purchase" badge on other people's reviews |
The resolver is a list<int> → list<int> function: the component calls it once with all
authors on the page and gets back the verified ones. The component is shared and knows nothing
about orders; without a resolver (blog, profile) there is no badge. It does not decide who may
write a review.
The reviewsDetailed style already contains the summary
On a page with STYLE_REVIEWS_DETAILED do not add STYLE_SUMMARY as well, otherwise the same
numbers are printed twice.
Rating summary#
Listings do not read individual ratings but the summary on the thread (RatingThread):
rating_count, rating_avg and count_1 to count_5, computed only from ratings with
active = 1.
| Who recalculates the summary | When |
|---|---|
RatingManager::afterSave() → recalcThread() |
after every rating save, only its thread |
RatingManager::afterDelete() |
after a rating delete (also a batch delete([…])), the threads of the deleted ratings, in the same transaction |
RatingManager::deleteAll() |
after a bulk delete by filters, the affected threads |
RatingManager::updateColumns() |
after a change of approved, deleted, value or threadId, the threads before and after the change; other columns (for example userName) do not trigger it |
cron /cron/base/rating/recalc → recalcAllThreads() |
daily; one set-based UPDATE over all threads, a safety net against drift; a thread with no active ratings is reset to 0 |
Raw SQL does not recalculate the summary
A delete and a column write through the manager recalculate the summary right away. When
you change ratings with your own SQL outside the manager, call
RatingManager::recalcThread($threadId) afterwards, otherwise only the nightly cron fixes it.
One vote per visitor#
Repeated ratings and votes on the usefulness of a review are guarded by
DuplicateVoterGuardTrait::voterFilters() (RatingManager::hasRated(),
RatingVoteManager::hasVoted()):
| Who votes | How the previous vote is looked up |
|---|---|
| logged in | userId, the IP is ignored (one account = one vote from any device) |
| anonymous | userId IS NULL AND ip = … |
| anonymous without an IP | cannot be decided, the method returns null |
A unique index does not guard an anonymous voter
A UNIQUE(…, user_id) index does not treat two NULL values as equal, so without an
application check an anonymous visitor could vote again and again. A new voting entity
needs the same check in the Api and must read null from voterFilters() as "cannot
verify", not as "has not voted".
Approval of a new comment or rating#
Whether a new comment or rating is published right away is decided by the Api manager's
beforeSave() from the Base module settings (module 2). In the administration they are under
Site settings → the section Komentáře, hodnocení a newsletter (comments, ratings and
newsletter).
| Key | Applies to | When the row is missing |
|---|---|---|
comment.useAutoApproval |
every new comment (CommentManager) |
1 = approved right away |
rating.useAutoApprovalStars |
a stars-only rating (RatingManager) |
0 = waits for approval |
rating.useAutoApprovalReviews |
a written review — text, pros, cons (Rating::isReview()) |
0 = waits for approval |
The front rating component may override both rating keys for one instance
(setOverrideRatingUseAutoApprovalStars(), setOverrideRatingUseAutoApprovalReview()); the
value goes into $meta under the same key and $meta wins over the database. Editing an
existing record does not touch the approval state — otherwise every save from the
administration would overwrite a manual approval, unapproval or soft delete.
Comments and ratings have opposite defaults
Comments have always been published right away, so a missing row means 1. Unifying
the default to 0 would silently switch on moderation for every module and comments
would vanish from the site after submit. Moderated mode is switched on by storing 0
in the row.
The review key is plural
rating.useAutoApprovalReviews, like …Stars. Reading …Review would never find the
row and reviews would always go to moderation, whatever the switch says.
Insert gates#
The checks run in the Api manager's beforeSave(), because every insert passes through
it — the front, the administration through ApiBridge and the external :Api:Base:*:save. A
form may show its own message on top, but the manager gate is the one that counts.
| Manager | Check | Response |
|---|---|---|
CommentManager |
content creation quota (base-comment) |
RATE_LIMIT_EXCEEDED |
CommentManager |
the parent of a reply belongs to the same thread (rejectForeignParent(), one query on the pair id + threadId) |
INVALID_VALUE, errorCode: commentParentThreadMismatch |
RatingManager |
the rating thread exists | INVALID_DATA |
RatingManager |
a logged-in author is not the owner of the rated object | INVALID_DATA, errorCode: selfRating |
RatingManager |
the same visitor has not rated here yet (hasRated(), see above) |
DB_DUPLICATE_ENTRY, errorCode: alreadyRated |
A comment whose parent is in another thread vanishes from the tree
threadId and parentId are two independent scalars. The tree is loaded by threadId
only, so the parent is missing from the loaded set and the reply is rendered nowhere,
although it is in the database. A new UI that creates replies must therefore not bypass
the manager gate.
Reply to a rating: the author and the rated party#
A rating has two different people who may do something with it, and each has its own action.
| Who | Where it comes from | May | Action and role |
|---|---|---|---|
| the review author | Rating::$userId (RatingManager::isOwnedBy()) |
edit their review | :Api:Base:Rating:save, role owner |
| the owner of the rated object | from the DB: rating → thread → object → owner (RatingManager::findRespondableOwner()) |
write or delete the reply (response) |
:Api:Base:Rating:saveReply, role receiver |
Only RatingManager::saveReply() writes the reply; responseDatetime is set by the server
and cleared when the reply is deleted. The front asks internalActionCanRespondTo() beforehand; the
answer is in data.canRespond and any other shape reads as false.
The generic save is wrapped (RatingPresenter::internalActionSave()): when the payload changes
the reply compared with the DB state (RatingManager::payloadChangesReply()), the caller
must also pass the saveReply guard. Without it the author could write a "seller reply" into
their own review through save.
save payload |
payloadChangesReply() |
|---|---|
no response key |
false |
a new rating with an empty reply (the front sends response => null) |
false, no database query |
| a new rating with a reply | true |
| an update where the normalised reply differs from the stored one (deletion too) | true |
an unknown id |
true — the guard decides |
Today the owner can be derived only for a user profile (ThreadType::SystemUser, link
User::$ratingThreadId). For other types (article, product, catalogue advert…)
findRespondableOwner() returns null and nobody but an administrator may reply.
Procedure: allow replies for another type of rated object
- Extend
RatingManager::findThreadOwner()with a branch for the newThreadType: find the object by itsratingThreadIdcolumn and return its owner (User). - Change nothing else.
canRespondTo(), thesaveReplyguard and thesavewrapper ask the same method, so the rule applies everywhere at once. The same method also stops the owner from rating their own object (selfRating). - Verify the owner's reply, the denial of the review author and the denial of a stranger
(
RatingSaveReplyReceiverGateTestis the model).
Do not confuse isOwnedBy() and canRespondTo()
A naive rating.userId === jwt.userId for a reply gives exactly the opposite result: the
review author could reply to themselves and the rated party could not.
Do not call saveReply() outside the guarded action
The manager does not verify ownership again — the presenter guard, the only place that knows the identity from the JWT, has decided. A call from anywhere else bypasses authorisation.
Without the saveReply privilege row nobody can reply
When the key :Api:Base:Rating:saveReply is missing in the database, the external path
denies everyone, the administrator included. The emergency mode internalMode: off lets
the front through, so the fault shows only after switching to enforce.
Usefulness votes and review reports#
"Was this review helpful?" and "Report" are written by RatingVoteManager into one table
(RatingVote, told apart by kind). Only the internal RatingPresenter actions are called —
vote, report and myVotes (how the visitor voted on the reviews on the page). They have no
action*(), and a CRUD over RatingVote deliberately does not exist: a generic save would
accept a foreign userId and any ip and bypass the duplicate check.
| Situation | Response |
|---|---|
| the rating does not exist or is not active (unapproved, deleted) | ITEM_NOT_FOUND |
| the author votes on their own review | PERMISSION_DENIED |
anonymous and rating.allowAnonymousReviewVote is not 1 (a missing row means "no") |
PERMISSION_DENIED |
| a repeated vote or report | DB_DUPLICATE_ENTRY — rejected, not overwritten |
| success | the row is stored and the count on the rating is incremented in the DB (incrementCount()), not overwritten |
A report neither hides nor deletes the review; it only raises reports and the administrator
filters by it. The caller must take userId and ip from the server-side identity (front:
session and getRemoteAddress()), never from the payload.
Do not add a unique key on (rating_id, ip)
Logged-in users are covered by UNIQUE uq_rating_user, also against concurrent
requests; anonymous ones are guarded by the application by IP. A unique key on the IP
would block two logged-in users from the same network.
Poll vote#
PollPresenter::internalActionRegisterVote() (externally actionRegisterVote()) hydrates the whole
PollVote from the payload, but always overwrites ip and insertDatetime with server
values before hydration. The IP is part of the primary key and the only protection against
repeated voting.
#[ExternalReadonly] guards nothing in a custom action
The attribute strip runs only in SaveTrait::saveEntityData() (the generic save). A
custom action that hydrates an entity from the payload must overwrite server-computed
fields itself — otherwise the caller sends voteData[ip] and votes again and again.
Comments and the query cache#
Reading comments does not depend on the caller, so CommentPresenter has the query cache
enabled like the other presenters and comment listings do not hit the database every time.
Do not put private communication among comments
The read result would start to depend on the caller, and the wire-payload cache is
user-agnostic: an administrator would warm it and a guest would read a foreign
conversation, or the other way round. isQueryCacheEnabled(): false,
filterReadPayload() and filters in managers and presenters would all have to come
back. Private communication gets its own table (models: work item comments in
My company, Private messages).
Comments and ratings in an object's edit form#
The comments or ratings tab in the edit form of an article, product or advert is rendered by
the shared admin components CommentThread and RatingThread. The component is keyed by the
thread id, so any edit form that owns a thread can embed it. Adding and editing run in the
shared admin modal window, deleting is soft (the deleted flag).
The thread travels in the form as the hidden field threadId. The client can rewrite that
field, so it is only an echo: which thread gets written is decided by the server-side
context the host passes to the component when creating it.
| Where | What is checked | On mismatch |
|---|---|---|
signals add…!, edit…!, toggleDeleted! |
threadId from the URL = host context |
403 |
| form submit | hidden threadId = context; the reply parent and the edited record are looked up by id + threadId |
nothing is saved, the modal shows an error |
| no context | nothing | the component saves nothing and opens no modal |
Procedure: adding comments or ratings to another edit form
- Inject the factory (
CommentThreadFactory,RatingThreadFactory) through the constructor of the presenter or the edit component. -
Set the context in the component factory — a callback returning the thread of the entity being edited:
protected function createComponentCommentThread(): CommentThread { return $this->commentThreadFactory->create() ->setThreadContext(fn(): ?int => $this->editedArticle?->getCommentThreadId()); }The callback runs only at signal or submit time, when the host's
action*()has already loaded the entity. 3. Put{control commentThread $commentThreadId}({control ratingThread $ratingThreadId}) in the tab template; pass the thread id fromrender*(). 4. Check privileges: the components guard:Admin:Base:Comment:edit/:deland:Admin:Base:Rating:edit/:delthemselves, also for a hand-built URL.
Without setThreadContext() the component silently does nothing
The list renders, but the Add and Edit buttons open no modal (the signal ends with 403)
and saving writes nothing. The context guards against a forged threadId writing
a comment or rating to someone else's object.
Moderation across sections. Next to the tabs there are two central lists over the whole
tables: CommentPresenter and RatingPresenter (thread detail CommentThreadPresenter,
RatingThreadPresenter). The section (e-shop, blog…) is stored on the thread as type, not on
the comment row. An association to the thread cannot be mapped, because the scalar threadId
already maps that column and Doctrine rejects a second writable mapping. The list therefore
loads the threads separately and pairs them through a threadId => type map; a module's menu
link is the same list with type prefilled. The list cannot show the concrete article or
product: the object holds the link (commentThreadId, ratingThreadId), not the thread.
The section map loads every thread
A thread exists for every commentable object, so with a large catalogue the list slows
down. It then needs to fetch types only for the threadIds on the current page. Threads
without a type (the column is nullable) have an empty section and the section filter
does not find them.
Article lists on the front end#
The article boxes on the home page and in the side columns are thin wrappers over
ArticleListProvider. A wrapper only sets the order (in its template as $orderBy), maybe an
extra filter, and passes them on; loading and rendering is done by the provider.
| Component | Order | Extra filter | Default style, count |
|---|---|---|---|
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; its own loading, not through the provider |
Comment and rating counts are not aggregated: the shared threads hold them
(cms_mod_base_comment_threads.comment_count, cms_mod_base_rating_threads.rating_avg
and rating_count), which the article points to through a nullable FK. The order goes through
the association, which JoinHelper joins with a LEFT JOIN, so an article without a thread does
not drop out of the list, it only falls to the end. The second key for ratings is intentional:
without it an article with a single five would outrank an article with a hundred ratings
averaging 4.9.
The provider always adds activated = 1 and published <= NOW to the caller's filters. It
assigns them after the caller's filters under the keys activated and published, so the
caller cannot override them, not even with the same key. Filter objects are built in the
component's PHP, not in Latte (MainArticleList hands $filters to its template ready-made).
Procedure: add an article box with its own order#
- Copy the nearest wrapper (
LastArticleListfor a plain order,MainArticleListwhen you need a filter). - Set
$orderByin the wrapper's template; write an order through a thread as a dotted path (ratingThread.ratingAvg), not as a scalar. - Build the extra filter in
render()and pass it as$filtersunder a key that does not clash withactivatedandpublished. - Check the provider's
include: theLinestyle prints the comment count fromcommentThread; without it there would be nothing to print.
Without a marked main article the lead block is empty
With no article carrying the main flag, MainArticleList renders the "no articles"
message and does not substitute the newest article. This is intentional: the editors must
notice that they picked no headline anywhere. Several articles may carry the flag; the
caller's order and limit pick among them.
TaggedArticleList with empty tags lists all articles
Empty $tagIds add no tag filter at all and the component behaves as a plain list of the
newest (id ↓). A tag without articles, on the other hand, gives an empty list with the
usual message.
showOnlyCategoryIds limits the list to categories
All wrappers and ArticleListProvider::render() pass it to InitRenderTrait::initRender(),
which builds a categories.id IN (...) filter from it (same shape as tags.id in
TaggedArticleList). An article needs to be in one of the categories. An empty list adds no
filter and the list shows all categories. The parameter overrides a categoryId key in
$filters.
Gallery on the home page#
The gallery on the home page is drawn by LastGallery: it picks the newest active and published
gallery (or the given $galleryId) and hands rendering over to frontSystemGallery. It skips
galleries with only_for_logged, even when their id is given, because on a public page half of
the visitors could not reach them. Photos above $maxAssets are summed into "+ N".
Carousel#
A carousel from the administration (Blog → Carousels) is drawn on the website by the component
frontBlogCarousel (app/UI/Front/Blog/Components/CarouselComponent/):
{control frontBlogCarousel 1} {* carousel 1, one slide per view *}
{control frontBlogCarousel 1, 3} {* three slides per view *}
| Rule | How |
|---|---|
| which carousel | only an active one (activated = 1) with an empty or past publication time |
| slides | the slide's HTML code in the page language; a slide without code in that language is skipped, without slides nothing is drawn |
| skeleton | the shared .list--carousel (like flash messages); arrows, dots and the step come from the same script, the number of visible slides is carried by --carousel-cols |
| arrows and dots | only when there are more slides than visible ones ($perPage) |
| permissions | reading :Api:Blog:Carousel:getAll is granted to guests and logged-in users, like flash messages |
The slide code is printed unescaped
A slide is HTML from the administration and the template prints it with |noescape. Writing
slides must therefore not be granted to roles outside the administration; a new write path
from the front or an external client would need purification in the Api beforeSave().
Content images#
Images do not travel through the object graph. The administration sends them in $meta and
the Api manager processes them in afterSave(): it stores the Asset with its variants,
writes the link and releases the images that dropped out of the form (see
Image variants).
| Entity | Keys in $meta |
Missing key |
|---|---|---|
Article, Category, Event, FlashMessage (one image) |
image = {name, data} (base64) → a new image; imageDelete = true without image → the asset foreign key is cleared |
the image stays |
Gallery |
images = a list of rows in gallery order; a row carries image (new) or assetId (kept) and texts[<langId>] = {title, description} |
without the images key the gallery stays as it is |
Intro (one image per language) |
imageLanguages = ids of the form languages; for each image_<langId> (new), imageDelete_<langId>, imageCurrentId_<langId> (kept) |
without imageLanguages the links stay as they are |
The gallery and the intro panel write their links through updateColumn('assetRels', …),
that is by full replacement. Whatever is not in $meta disappears from the gallery. An
external client may send only the gallery images (nested in data, $graphDataKeys); the
other keys live only in $meta, which the external path drops.
Procedure: your own write of gallery or panel images
- Load the current images and send all of them: rows with
assetIdfor a gallery,imageCurrentId_<langId>for every language inimageLanguagesfor a panel. - For a kept gallery image send
textstoo, otherwise it loses its captions. - The order of gallery rows is the order of images; an empty row and a repeated image are skipped.
- Check the links in the database (
cms_mod_blog_gallery_image_relations,cms_mod_blog_intro_image_relations) and that the dropped images are gone fromcms_system_images.
A language in imageLanguages without an image loses its image
When neither a new image nor imageCurrentId_<langId> comes for a language, no link is
written and the image is released, files included. The same happens to a gallery image
missing from images.
Gallery image captions are rewritten in full
The texts are written by delete and insert (updateColumn('translations', …)) for every
row, kept ones included. A row without texts deletes the captions in all languages. A
language with an empty title and description is skipped.
Scheduled tasks#
| Endpoint | What it does | Recommended interval |
|---|---|---|
/cron/blog/elastic/sync |
incremental index synchronisation | every 10 min |
/cron/blog/elastic/reindex |
a full reindex | nightly |
/cron/blog/sitemap/{article,event,news} |
content sitemaps | nightly |
/cron/blog/sitemap/{article,event,news}-category |
category sitemaps | nightly |
Where to look#
| I want | Where |
|---|---|
| the model search source | app/UI/Api/Blog/Models/Search/ArticleDocumentSource.php |
| the model presenter split | app/UI/Front/Blog/Presenters/Traits/ |
| the content managers | app/UI/Api/Blog/Models/Managers/ |
| the shared comments and ratings | app/UI/Api/Base/Models/Entities/ |
| approval, insert gates, the reply to a rating | app/UI/Api/Base/Models/Managers/{Comment,Rating}Manager.php, app/UI/Api/Base/Presenters/RatingPresenter.php |
| usefulness votes and reports | app/UI/Api/Base/Models/Managers/RatingVoteManager.php |
| the cron tasks | app/UI/Cron/Blog/Presenters/ |
| the target of category and region links | app/UI/Front/Blog/Components/Traits/BlogListTargetTrait.php |
| the region on the front | app/UI/Front/Blog/Presenters/Traits/Inits/RegionTrait.php |
| article boxes and the gallery on the home page | app/UI/Front/Blog/Components/{ArticleListProvider,MainArticleList,TaggedArticleList,LastGallery}Component/ |
Follow-up chapters: Elasticsearch · Discussion · Translations · Code conventions