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