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