PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / app / Sitemap / SitemapService.php

SitemapService.php in Yatra – Travel Booking & Tour Operator Software 3.0.16, at app/Sitemap/SitemapService.php

347 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Sitemap;
6
7 use Yatra\Services\SettingsService;
8
9 use Yatra\Database\Tables\ClassificationsTable;
10 use Yatra\Database\Tables\TripsTable;
11
12 /**
13 * Single source of truth for every public Yatra URL that belongs in a sitemap.
14 *
15 * Yatra stores trips and classifications (destinations / activities /
16 * categories) in custom tables, not as WordPress posts, so no sitemap
17 * generator (WP core, Yoast, Rank Math, AIOSEO) can discover them on its own.
18 * This service enumerates every such URL with its last-modified date.
19 *
20 * Everything is exposed as ONE consolidated list (getAllEntries) so Yatra
21 * publishes a single, clean /yatra-sitemap.xml. Per-type access is kept
22 * internal/available but the public sitemap is one file.
23 *
24 * Each entry is: ['loc' => absolute URL, 'lastmod' => W3C datetime|''].
25 */
26 class SitemapService
27 {
28 public const TYPE_ARCHIVE = 'archive';
29 public const TYPE_TRIP = 'trip';
30 public const TYPE_DESTINATION = 'destination';
31 public const TYPE_ACTIVITY = 'activity';
32 public const TYPE_CATEGORY = 'category';
33
34 /** Order entries appear in the consolidated sitemap. */
35 private const TYPES = [
36 self::TYPE_ARCHIVE,
37 self::TYPE_TRIP,
38 self::TYPE_DESTINATION,
39 self::TYPE_ACTIVITY,
40 self::TYPE_CATEGORY,
41 ];
42
43 /**
44 * Per-request memo. Held as a class property rather than a method static so
45 * flushCache() can actually clear it — a flush that only dropped the
46 * transient left this stale for the rest of the request.
47 *
48 * @var array<int, array{loc: string, lastmod: string}>|null
49 */
50 private static $runtime = null;
51
52 private const CACHE_KEY = 'yatra_sitemap_entries_all';
53
54 /** Setting holding the types the operator wants published. */
55 private const TYPES_SETTING = 'sitemap_types';
56 private const CACHE_TTL = HOUR_IN_SECONDS;
57
58 /** sitemaps.org caps a single sitemap file at 50,000 URLs. */
59 private const MAX_URLS = 50000;
60
61 /**
62 * Every public Yatra URL, merged into one de-duplicated, cached list.
63 *
64 * @return array<int, array{loc: string, lastmod: string}>
65 */
66 public function getAllEntries(): array
67 {
68 if (self::$runtime !== null) {
69 return self::$runtime;
70 }
71
72 $cached = get_transient(self::CACHE_KEY);
73 if (is_array($cached)) {
74 self::$runtime = $cached;
75 return $cached;
76 }
77
78 $entries = [];
79 foreach ($this->enabledTypes() as $type) {
80 foreach ($this->entriesForType($type) as $entry) {
81 $entries[] = $entry;
82 }
83 }
84 $entries = $this->dedupe($entries);
85
86 /**
87 * Final say over the sitemap's entries — lets integrations drop
88 * noindex URLs, add multilingual alternates, etc.
89 *
90 * @param array<int, array{loc: string, lastmod: string}> $entries
91 */
92 $entries = (array) apply_filters('yatra_sitemap_entries', $entries);
93
94 // Never emit more than a single sitemap file may legally hold. Realistic
95 // travel catalogues are orders of magnitude below this; the cap is a
96 // safety valve so an extreme catalogue can't produce an invalid file.
97 $max = (int) apply_filters('yatra_sitemap_max_urls', self::MAX_URLS);
98 if ($max > 0 && count($entries) > $max) {
99 $entries = array_slice($entries, 0, $max);
100 }
101
102 set_transient(self::CACHE_KEY, $entries, self::CACHE_TTL);
103 self::$runtime = $entries;
104
105 return $entries;
106 }
107
108 public function getCount(): int
109 {
110 return count($this->getAllEntries());
111 }
112
113 /**
114 * Newest lastmod across all entries (for index <lastmod> in SEO plugins).
115 */
116 public function getLatestLastmod(): string
117 {
118 $latest = '';
119 foreach ($this->getAllEntries() as $entry) {
120 if ($entry['lastmod'] !== '' && $entry['lastmod'] > $latest) {
121 $latest = $entry['lastmod'];
122 }
123 }
124
125 return $latest;
126 }
127
128 /**
129 * Drop the cached list. Call when a trip or classification changes.
130 */
131 /**
132 * Types the operator has chosen to publish, in the canonical order.
133 *
134 * An unset or empty setting means "everything", which is what every site
135 * had before this was configurable — so an upgrade changes nothing until
136 * the operator actually makes a choice.
137 *
138 * @return string[]
139 */
140 public function enabledTypes(): array
141 {
142 $selected = null;
143
144 if (class_exists(SettingsService::class)) {
145 $selected = SettingsService::get(self::TYPES_SETTING, null);
146 }
147
148 if (!is_array($selected) || $selected === []) {
149 return self::TYPES;
150 }
151
152 $selected = array_map('strval', $selected);
153
154 // Intersect rather than trust the stored list, so an unknown or stale
155 // type can never introduce an entry type this class cannot build.
156 $types = array_values(array_intersect(self::TYPES, $selected));
157
158 /**
159 * Final say over which Yatra content types the sitemap publishes.
160 *
161 * @param string[] $types Selected types, canonical order.
162 * @param string[] $selected Raw stored selection.
163 */
164 return (array) apply_filters('yatra_sitemap_types', $types, $selected);
165 }
166
167 /**
168 * Is this content type published in the sitemap?
169 *
170 * Also answers "should this page be noindexed?" for the SEO layer, which
171 * is why it is public.
172 */
173 public function isTypeEnabled(string $type): bool
174 {
175 return in_array($type, $this->enabledTypes(), true);
176 }
177
178 public static function flushCache(): void
179 {
180 self::$runtime = null;
181 delete_transient(self::CACHE_KEY);
182 }
183
184 /**
185 * @return array<int, array{loc: string, lastmod: string}>
186 */
187 private function entriesForType(string $type): array
188 {
189 switch ($type) {
190 case self::TYPE_ARCHIVE:
191 return $this->archiveEntries();
192 case self::TYPE_TRIP:
193 return $this->tripEntries();
194 case self::TYPE_DESTINATION:
195 case self::TYPE_ACTIVITY:
196 case self::TYPE_CATEGORY:
197 return $this->classificationEntries($type);
198 default:
199 return [];
200 }
201 }
202
203 /**
204 * The trip listing page plus each taxonomy index page.
205 *
206 * @return array<int, array{loc: string, lastmod: string}>
207 */
208 private function archiveEntries(): array
209 {
210 $entries = [];
211
212 $listing = yatra_get_trip_listing_url();
213 if ($listing !== '') {
214 $entries[] = ['loc' => $listing, 'lastmod' => ''];
215 }
216
217 if (function_exists('yatra_get_taxonomy_listing_url')) {
218 foreach (['destination', 'activity', 'trip-category'] as $listingType) {
219 $url = yatra_get_taxonomy_listing_url($listingType);
220 if ($url !== '' && $url !== home_url('/')) {
221 $entries[] = ['loc' => $url, 'lastmod' => ''];
222 }
223 }
224 }
225
226 return $entries;
227 }
228
229 /**
230 * @return array<int, array{loc: string, lastmod: string}>
231 */
232 private function tripEntries(): array
233 {
234 global $wpdb;
235
236 $table = TripsTable::getTableName();
237 $rows = $wpdb->get_results(
238 "SELECT id, slug, updated_at
239 FROM `{$table}`
240 WHERE status IN ('publish', 'published')
241 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
242 AND slug <> ''
243 ORDER BY updated_at DESC"
244 );
245
246 $entries = [];
247 foreach ((array) $rows as $row) {
248 $loc = yatra_get_trip_permalink($row);
249 if ($loc === '') {
250 continue;
251 }
252 $entries[] = ['loc' => $loc, 'lastmod' => $this->toW3c((string) ($row->updated_at ?? ''))];
253 }
254
255 return $entries;
256 }
257
258 /**
259 * @return array<int, array{loc: string, lastmod: string}>
260 */
261 private function classificationEntries(string $type): array
262 {
263 global $wpdb;
264
265 $table = ClassificationsTable::getTableName();
266 $rows = $wpdb->get_results(
267 $wpdb->prepare(
268 "SELECT id, slug, updated_at
269 FROM `{$table}`
270 WHERE type = %s
271 AND status = 'publish'
272 AND slug <> ''
273 ORDER BY updated_at DESC",
274 $type
275 )
276 );
277
278 $resolver = $this->permalinkResolver($type);
279
280 $entries = [];
281 foreach ((array) $rows as $row) {
282 $loc = $resolver($row);
283 if ($loc === '') {
284 continue;
285 }
286 $entries[] = ['loc' => $loc, 'lastmod' => $this->toW3c((string) ($row->updated_at ?? ''))];
287 }
288
289 return $entries;
290 }
291
292 /**
293 * @return callable(object): string
294 */
295 private function permalinkResolver(string $type): callable
296 {
297 switch ($type) {
298 case self::TYPE_DESTINATION:
299 return static fn ($row) => yatra_get_destination_permalink($row);
300 case self::TYPE_ACTIVITY:
301 return static fn ($row) => yatra_get_activity_permalink($row);
302 case self::TYPE_CATEGORY:
303 return static fn ($row) => yatra_get_category_permalink($row);
304 default:
305 return static fn ($row) => '';
306 }
307 }
308
309 /**
310 * Convert a MySQL datetime to a W3C / ISO-8601 string for <lastmod>.
311 */
312 private function toW3c(string $mysqlDate): string
313 {
314 $mysqlDate = trim($mysqlDate);
315 if ($mysqlDate === '' || strpos($mysqlDate, '0000-00-00') === 0) {
316 return '';
317 }
318
319 $timestamp = strtotime($mysqlDate);
320 if ($timestamp === false) {
321 return '';
322 }
323
324 return gmdate('Y-m-d\TH:i:s+00:00', $timestamp);
325 }
326
327 /**
328 * @param array<int, array{loc: string, lastmod: string}> $entries
329 * @return array<int, array{loc: string, lastmod: string}>
330 */
331 private function dedupe(array $entries): array
332 {
333 $seen = [];
334 $out = [];
335 foreach ($entries as $entry) {
336 $loc = $entry['loc'];
337 if ($loc === '' || isset($seen[$loc])) {
338 continue;
339 }
340 $seen[$loc] = true;
341 $out[] = $entry;
342 }
343
344 return $out;
345 }
346 }
347