xref: /webtrees/app/Module/SiteMapModule.php (revision a37bbafbe70ea5bf9b558bcf7f3b6e68d377f38e)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2018 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16namespace Fisharebest\Webtrees\Module;
17
18use Fisharebest\Webtrees\Database;
19use Fisharebest\Webtrees\FlashMessages;
20use Fisharebest\Webtrees\GedcomRecord;
21use Fisharebest\Webtrees\Html;
22use Fisharebest\Webtrees\I18N;
23use Fisharebest\Webtrees\Individual;
24use Fisharebest\Webtrees\Media;
25use Fisharebest\Webtrees\Note;
26use Fisharebest\Webtrees\Repository;
27use Fisharebest\Webtrees\Source;
28use Fisharebest\Webtrees\Tree;
29use Symfony\Component\HttpFoundation\RedirectResponse;
30use Symfony\Component\HttpFoundation\Request;
31use Symfony\Component\HttpFoundation\Response;
32use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
33
34/**
35 * Class SiteMapModule
36 */
37class SiteMapModule extends AbstractModule implements ModuleConfigInterface {
38	const RECORDS_PER_VOLUME = 500; // Keep sitemap files small, for memory, CPU and max_allowed_packet limits.
39	const CACHE_LIFE         = 1209600; // Two weeks
40
41	/**
42	 * How should this module be labelled on tabs, menus, etc.?
43	 *
44	 * @return string
45	 */
46	public function getTitle() {
47		return /* I18N: Name of a module - see http://en.wikipedia.org/wiki/Sitemaps */
48			I18N::translate('Sitemaps');
49	}
50
51	/**
52	 * A sentence describing what this module does.
53	 *
54	 * @return string
55	 */
56	public function getDescription() {
57		return /* I18N: Description of the “Sitemaps” module */
58			I18N::translate('Generate sitemap files for search engines.');
59	}
60
61	/**
62	 * The URL to a page where the user can modify the configuration of this module.
63	 *
64	 * @return string
65	 */
66	public function getConfigLink() {
67		return route('module', ['module' => $this->getName(), 'action' => 'Admin']);
68	}
69
70	/**
71	 * @param Request $request
72	 *
73	 * @return Response
74	 */
75	public function getAdminAction(Request $request): Response {
76		$this->layout = 'layouts/administration';
77
78		$sitemap_url = route('module', ['module' => 'sitemap', 'action' => 'Index']);
79
80		// This list comes from http://en.wikipedia.org/wiki/Sitemaps
81		$submit_urls = [
82			'Bing/Yahoo' => Html::url('https://www.bing.com/webmaster/ping.aspx', ['siteMap' => $sitemap_url]),
83			'Google'     => Html::url('https://www.google.com/webmasters/tools/ping', ['sitemap' => $sitemap_url]),
84		];
85
86		return $this->viewResponse('modules/sitemap/config', [
87			'all_trees'   => Tree::getAll(),
88			'sitemap_url' => $sitemap_url,
89			'submit_urls' => $submit_urls,
90			'title'       => $this->getTitle(),
91		]);
92	}
93
94	/**
95	 * @param Request $request
96	 *
97	 * @return RedirectResponse
98	 */
99	public function postAdminAction(Request $request): RedirectResponse {
100		foreach (Tree::getAll() as $tree) {
101			$include_in_sitemap = (bool) $request->get('sitemap' . $tree->getTreeId());
102			$tree->setPreference('include_in_sitemap', (string) $include_in_sitemap);
103		}
104
105		FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->getTitle()), 'success');
106
107		return new RedirectResponse($this->getConfigLink());
108	}
109
110	/**
111	 * @param Request $request
112	 *
113	 * @return Response
114	 */
115	public function getIndexAction(Request $request): Response {
116		$timestamp = (int) $this->getPreference('sitemap.timestamp');
117
118		if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) {
119			$content = $this->getPreference('sitemap.xml');
120		} else {
121			$count_individuals = Database::prepare(
122				"SELECT i_file, COUNT(*) FROM `##individuals` GROUP BY i_file"
123			)->execute()->fetchAssoc();
124
125			$count_media = Database::prepare(
126				"SELECT m_file, COUNT(*) FROM `##media` GROUP BY m_file"
127			)->execute()->fetchAssoc();
128
129			$count_notes = Database::prepare(
130				"SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='NOTE' GROUP BY o_file"
131			)->execute()->fetchAssoc();
132
133			$count_repositories = Database::prepare(
134				"SELECT o_file, COUNT(*) FROM `##other` WHERE o_type='REPO' GROUP BY o_file"
135			)->execute()->fetchAssoc();
136
137			$count_sources = Database::prepare(
138				"SELECT s_file, COUNT(*) FROM `##sources` GROUP BY s_file"
139			)->execute()->fetchAssoc();
140
141			$content = view('modules/sitemap/sitemap-index.xml', [
142				'all_trees'          => Tree::getAll(),
143				'count_individuals'  => $count_individuals,
144				'count_media'        => $count_media,
145				'count_notes'        => $count_notes,
146				'count_repositories' => $count_repositories,
147				'count_sources'      => $count_sources,
148				'last_mod'           => date('Y-m-d'),
149				'records_per_volume' => self::RECORDS_PER_VOLUME,
150			]);
151
152			$this->setPreference('sitemap.xml', $content);
153		}
154
155		return new Response($content, Response::HTTP_OK, [
156			'Content-Type' => 'application/xml',
157		]);
158	}
159
160	/**
161	 * @param Request $request
162	 *
163	 * @return Response
164	 */
165	public function getFileAction(Request $request): Response {
166		$file = $request->get('file', '');
167
168		if (!preg_match('/^(\d+)-([imnrs])-(\d+)$/', $file, $match))		 {
169			throw new NotFoundHttpException('Bad sitemap file');
170		}
171
172		$timestamp = (int) $this->getPreference('sitemap-' . $file . '.timestamp');
173
174		if ($timestamp > WT_TIMESTAMP - self::CACHE_LIFE) {
175			$content = $this->getPreference('sitemap-' . $file . '.xml');
176		} else {
177			$tree = Tree::findById((int) $match[1]);
178
179			if ($tree === null) {
180				throw new NotFoundHttpException('No such tree');
181			}
182
183			$records = $this->sitemapRecords($tree, $match[2], self::RECORDS_PER_VOLUME,
184				self::RECORDS_PER_VOLUME * $match[3]);
185
186			$content = view('modules/sitemap/sitemap-file.xml', ['records' => $records]);
187
188			$this->setPreference('sitemap.xml', $content);
189		}
190
191		return new Response($content, Response::HTTP_OK, [
192			'Content-Type' => 'application/xml',
193		]);
194	}
195
196	/**
197	 * @param Tree   $tree
198	 * @param string $type
199	 * @param int    $limit
200	 * @param int    $offset
201	 *
202	 * @return array
203	 */
204	private function sitemapRecords(Tree $tree, string $type, int $limit, int $offset): array {
205		switch ($type) {
206			case 'i':
207				$records = $this->sitemapIndividuals($tree, $limit, $offset);
208				break;
209
210			case 'm':
211				$records = $this->sitemapMedia($tree, $limit, $offset);
212				break;
213
214			case 'n':
215				$records = $this->sitemapNotes($tree, $limit, $offset);
216				break;
217
218			case 'r':
219				$records = $this->sitemapRepositories($tree, $limit, $offset);
220				break;
221
222			case 's':
223				$records = $this->sitemapSources($tree, $limit, $offset);
224				break;
225
226			default:
227				throw new NotFoundHttpException('Invalid record type: ' . $type);
228		}
229
230		// Skip records that no longer exist.
231		$records = array_filter($records);
232
233		// Skip private records.
234		$records = array_filter($records, function (GedcomRecord $record) {
235			return $record->canShow();
236		});
237
238		return $records;
239	}
240
241	/**
242	 * @param Tree $tree
243	 * @param int  $limit
244	 * @param int  $offset
245	 *
246	 * @return array
247	 */
248	private function sitemapIndividuals(Tree $tree, int $limit, int $offset): array {
249		$rows = Database::prepare(
250			"SELECT i_id AS xref, i_gedcom AS gedcom" .
251			" FROM `##individuals`" .
252			" WHERE i_file = :tree_id" .
253			" ORDER BY i_id" .
254			" LIMIT :limit OFFSET :offset"
255		)->execute([
256			'tree_id' => $tree->getTreeId(),
257			'limit'   => $limit,
258			'offset'  => $offset,
259		])->fetchAll();
260
261		$records = [];
262
263		foreach ($rows as $row) {
264			$records[] = Individual::getInstance($row->xref, $tree, $row->gedcom);
265		}
266
267		return $records;
268	}
269
270	/**
271	 * @param Tree $tree
272	 * @param int  $limit
273	 * @param int  $offset
274	 *
275	 * @return array
276	 */
277	private function sitemapMedia(Tree $tree, int $limit, int $offset): array {
278		$rows = Database::prepare(
279			"SELECT m_id AS xref, m_gedcom AS gedcom" .
280			" FROM `##media`" .
281			" WHERE m_file = :tree_id" .
282			" ORDER BY m_id" .
283			" LIMIT :limit OFFSET :offset"
284		)->execute([
285			'tree_id' => $tree->getTreeId(),
286			'limit'   => $limit,
287			'offset'  => $offset,
288		])->fetchAll();
289
290		$records = [];
291
292		foreach ($rows as $row) {
293			$records[] = Media::getInstance($row->xref, $tree, $row->gedcom);
294		}
295
296		return $records;
297	}
298
299	/**
300	 * @param Tree $tree
301	 * @param int  $limit
302	 * @param int  $offset
303	 *
304	 * @return array
305	 */
306	private function sitemapNotes(Tree $tree, int $limit, int $offset): array {
307		$rows = Database::prepare(
308			"SELECT o_id AS xref, o_gedcom AS gedcom" .
309			" FROM `##other`" .
310			" WHERE o_file = :tree_id AND o_type = 'NOTE'" .
311			" ORDER BY o_id" .
312			" LIMIT :limit OFFSET :offset"
313		)->execute([
314			'tree_id' => $tree->getTreeId(),
315			'limit'   => $limit,
316			'offset'  => $offset,
317		])->fetchAll();
318
319		$records = [];
320
321		foreach ($rows as $row) {
322			$records[] = Note::getInstance($row->xref, $tree, $row->gedcom);
323		}
324
325		return $records;
326	}
327
328	/**
329	 * @param Tree $tree
330	 * @param int  $limit
331	 * @param int  $offset
332	 *
333	 * @return array
334	 */
335	private function sitemapRepositories(Tree $tree, int $limit, int $offset): array {
336		$rows = Database::prepare(
337			"SELECT o_id AS xref, o_gedcom AS gedcom" .
338			" FROM `##other`" .
339			" WHERE o_file = :tree_id AND o_type = 'REPO'" .
340			" ORDER BY o_id" .
341			" LIMIT :limit OFFSET :offset"
342		)->execute([
343			'tree_id' => $tree->getTreeId(),
344			'limit'   => $limit,
345			'offset'  => $offset,
346		])->fetchAll();
347
348		$records = [];
349
350		foreach ($rows as $row) {
351			$records[] = Repository::getInstance($row->xref, $tree, $row->gedcom);
352		}
353
354		return $records;
355	}
356
357	/**
358	 * @param Tree $tree
359	 * @param int  $limit
360	 * @param int  $offset
361	 *
362	 * @return array
363	 */
364	private function sitemapSources(Tree $tree, int $limit, int $offset): array {
365		$rows = Database::prepare(
366			"SELECT s_id AS xref, s_gedcom AS gedcom" .
367			" FROM `##sources`" .
368			" WHERE s_file = :tree_id" .
369			" ORDER BY s_id" .
370			" LIMIT :limit OFFSET :offset"
371		)->execute([
372			'tree_id' => $tree->getTreeId(),
373			'limit'   => $limit,
374			'offset'  => $offset,
375		])->fetchAll();
376
377		$records = [];
378
379		foreach ($rows as $row) {
380			$records[] = Source::getInstance($row->xref, $tree, $row->gedcom);
381		}
382
383		return $records;
384	}
385}
386