xref: /webtrees/app/Module/FamilyTreeStatisticsModule.php (revision 81b514b4672980e5db010e9d89b55eaf131e798f)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Module;
21
22use Fisharebest\Webtrees\Auth;
23use Fisharebest\Webtrees\I18N;
24use Fisharebest\Webtrees\Individual;
25use Fisharebest\Webtrees\Services\ModuleService;
26use Fisharebest\Webtrees\Statistics;
27use Fisharebest\Webtrees\Tree;
28use Fisharebest\Webtrees\Validator;
29use Illuminate\Database\Capsule\Manager as DB;
30use Illuminate\Database\Query\Expression;
31use Illuminate\Support\Str;
32use Psr\Http\Message\ServerRequestInterface;
33
34use function app;
35use function array_slice;
36use function extract;
37use function var_dump;
38use function view;
39
40use const EXTR_OVERWRITE;
41
42/**
43 * Class FamilyTreeStatisticsModule
44 */
45class FamilyTreeStatisticsModule extends AbstractModule implements ModuleBlockInterface
46{
47    use ModuleBlockTrait;
48
49    /** Show this number of surnames by default */
50    private const DEFAULT_NUMBER_OF_SURNAMES = '10';
51
52    private ModuleService $module_service;
53
54    /**
55     * @param ModuleService $module_service
56     */
57    public function __construct(ModuleService $module_service)
58    {
59        $this->module_service = $module_service;
60    }
61
62    /**
63     * How should this module be identified in the control panel, etc.?
64     *
65     * @return string
66     */
67    public function title(): string
68    {
69        /* I18N: Name of a module */
70        return I18N::translate('Statistics');
71    }
72
73    /**
74     * A sentence describing what this module does.
75     *
76     * @return string
77     */
78    public function description(): string
79    {
80        /* I18N: Description of “Statistics” module */
81        return I18N::translate('The size of the family tree, earliest and latest events, common names, etc.');
82    }
83
84    /**
85     * Generate the HTML content of this block.
86     *
87     * @param Tree                 $tree
88     * @param int                  $block_id
89     * @param string               $context
90     * @param array<string,string> $config
91     *
92     * @return string
93     */
94    public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string
95    {
96        $statistics = app(Statistics::class);
97
98        $show_last_update     = $this->getBlockSetting($block_id, 'show_last_update', '1');
99        $show_common_surnames = $this->getBlockSetting($block_id, 'show_common_surnames', '1');
100        $number_of_surnames   = (int) $this->getBlockSetting($block_id, 'number_of_surnames', self::DEFAULT_NUMBER_OF_SURNAMES);
101        $stat_indi            = $this->getBlockSetting($block_id, 'stat_indi', '1');
102        $stat_fam             = $this->getBlockSetting($block_id, 'stat_fam', '1');
103        $stat_sour            = $this->getBlockSetting($block_id, 'stat_sour', '1');
104        $stat_media           = $this->getBlockSetting($block_id, 'stat_media', '1');
105        $stat_repo            = $this->getBlockSetting($block_id, 'stat_repo', '1');
106        $stat_surname         = $this->getBlockSetting($block_id, 'stat_surname', '1');
107        $stat_events          = $this->getBlockSetting($block_id, 'stat_events', '1');
108        $stat_users           = $this->getBlockSetting($block_id, 'stat_users', '1');
109        $stat_first_birth     = $this->getBlockSetting($block_id, 'stat_first_birth', '1');
110        $stat_last_birth      = $this->getBlockSetting($block_id, 'stat_last_birth', '1');
111        $stat_first_death     = $this->getBlockSetting($block_id, 'stat_first_death', '1');
112        $stat_last_death      = $this->getBlockSetting($block_id, 'stat_last_death', '1');
113        $stat_long_life       = $this->getBlockSetting($block_id, 'stat_long_life', '1');
114        $stat_avg_life        = $this->getBlockSetting($block_id, 'stat_avg_life', '1');
115        $stat_most_chil       = $this->getBlockSetting($block_id, 'stat_most_chil', '1');
116        $stat_avg_chil        = $this->getBlockSetting($block_id, 'stat_avg_chil', '1');
117
118        extract($config, EXTR_OVERWRITE);
119
120        if ($show_common_surnames) {
121            $query = DB::table('name')
122                ->where('n_file', '=', $tree->id())
123                ->where('n_type', '<>', '_MARNM')
124                ->where('n_surn', '<>', '')
125                ->where('n_surn', '<>', Individual::NOMEN_NESCIO)
126                ->select([
127                    $this->binaryColumn('n_surn', 'n_surn'),
128                    $this->binaryColumn('n_surname', 'n_surname'),
129                    new Expression('COUNT(*) AS total'),
130                ])
131                ->groupBy([
132                    $this->binaryColumn('n_surn'),
133                    $this->binaryColumn('n_surname'),
134                ]);
135
136            /** @var array<array<int>> $top_surnames */
137            $top_surnames = [];
138
139            foreach ($query->get() as $row) {
140                $row->n_surn = $row->n_surn === '' ? $row->n_surname : $row->n_surn;
141                $row->n_surn = I18N::strtoupper(I18N::language()->normalize($row->n_surn));
142
143                $top_surnames[$row->n_surn][$row->n_surname] ??= 0;
144                $top_surnames[$row->n_surn][$row->n_surname] += (int) $row->total;
145            }
146
147            uasort($top_surnames, static fn (array $x, array $y): int => array_sum($y) <=> array_sum($x));
148
149            $top_surnames = array_slice($top_surnames, 0, $number_of_surnames, true);
150
151            // Find a module providing individual lists
152            $module = $this->module_service
153                ->findByComponent(ModuleListInterface::class, $tree, Auth::user())
154                ->first(static fn (ModuleInterface $module): bool => $module instanceof IndividualListModule);
155
156            $surnames = view('lists/surnames-compact-list', [
157                'module'   => $module,
158                'totals'   => false,
159                'surnames' => $top_surnames,
160                'tree'     => $tree,
161            ]);
162        } else {
163            $surnames = '';
164        }
165
166        $content = view('modules/gedcom_stats/statistics', [
167            'show_last_update'     => $show_last_update,
168            'show_common_surnames' => $show_common_surnames,
169            'number_of_surnames'   => $number_of_surnames,
170            'stat_indi'            => $stat_indi,
171            'stat_fam'             => $stat_fam,
172            'stat_sour'            => $stat_sour,
173            'stat_media'           => $stat_media,
174            'stat_repo'            => $stat_repo,
175            'stat_surname'         => $stat_surname,
176            'stat_events'          => $stat_events,
177            'stat_users'           => $stat_users,
178            'stat_first_birth'     => $stat_first_birth,
179            'stat_last_birth'      => $stat_last_birth,
180            'stat_first_death'     => $stat_first_death,
181            'stat_last_death'      => $stat_last_death,
182            'stat_long_life'       => $stat_long_life,
183            'stat_avg_life'        => $stat_avg_life,
184            'stat_most_chil'       => $stat_most_chil,
185            'stat_avg_chil'        => $stat_avg_chil,
186            'surnames'             => $surnames,
187        ]);
188
189        $content = $statistics->embedTags($content);
190
191        if ($context !== self::CONTEXT_EMBED) {
192            return view('modules/block-template', [
193                'block'      => Str::kebab($this->name()),
194                'id'         => $block_id,
195                'config_url' => $this->configUrl($tree, $context, $block_id),
196                'title'      => $this->title(),
197                'content'    => $content,
198            ]);
199        }
200
201        return $content;
202    }
203
204    /**
205     * Should this block load asynchronously using AJAX?
206     *
207     * Simple blocks are faster in-line, more complex ones can be loaded later.
208     *
209     * @return bool
210     */
211    public function loadAjax(): bool
212    {
213        return true;
214    }
215
216    /**
217     * Can this block be shown on the user’s home page?
218     *
219     * @return bool
220     */
221    public function isUserBlock(): bool
222    {
223        return true;
224    }
225
226    /**
227     * Can this block be shown on the tree’s home page?
228     *
229     * @return bool
230     */
231    public function isTreeBlock(): bool
232    {
233        return true;
234    }
235
236    /**
237     * Update the configuration for a block.
238     *
239     * @param ServerRequestInterface $request
240     * @param int     $block_id
241     *
242     * @return void
243     */
244    public function saveBlockConfiguration(ServerRequestInterface $request, int $block_id): void
245    {
246        $show_last_update     = Validator::parsedBody($request)->boolean('show_last_update', false);
247        $show_common_surnames = Validator::parsedBody($request)->boolean('show_common_surnames', false);
248        $number_of_surnames   = Validator::parsedBody($request)->integer('number_of_surnames');
249        $stat_indi            = Validator::parsedBody($request)->boolean('stat_indi', false);
250        $stat_fam             = Validator::parsedBody($request)->boolean('stat_fam', false);
251        $stat_sour            = Validator::parsedBody($request)->boolean('stat_sour', false);
252        $stat_other           = Validator::parsedBody($request)->boolean('stat_other', false);
253        $stat_media           = Validator::parsedBody($request)->boolean('stat_media', false);
254        $stat_repo            = Validator::parsedBody($request)->boolean('stat_repo', false);
255        $stat_surname         = Validator::parsedBody($request)->boolean('stat_surname', false);
256        $stat_events          = Validator::parsedBody($request)->boolean('stat_events', false);
257        $stat_users           = Validator::parsedBody($request)->boolean('stat_users', false);
258        $stat_first_birth     = Validator::parsedBody($request)->boolean('stat_first_birth', false);
259        $stat_last_birth      = Validator::parsedBody($request)->boolean('stat_last_birth', false);
260        $stat_first_death     = Validator::parsedBody($request)->boolean('stat_first_death', false);
261        $stat_last_death      = Validator::parsedBody($request)->boolean('stat_last_death', false);
262        $stat_long_life       = Validator::parsedBody($request)->boolean('stat_long_life', false);
263        $stat_avg_life        = Validator::parsedBody($request)->boolean('stat_avg_life', false);
264        $stat_most_chil       = Validator::parsedBody($request)->boolean('stat_most_chil', false);
265        $stat_avg_chil        = Validator::parsedBody($request)->boolean('stat_avg_chil', false);
266
267        $this->setBlockSetting($block_id, 'show_last_update', (string) $show_last_update);
268        $this->setBlockSetting($block_id, 'show_common_surnames', (string) $show_common_surnames);
269        $this->setBlockSetting($block_id, 'number_of_surnames', (string) $number_of_surnames);
270        $this->setBlockSetting($block_id, 'stat_indi', (string) $stat_indi);
271        $this->setBlockSetting($block_id, 'stat_fam', (string) $stat_fam);
272        $this->setBlockSetting($block_id, 'stat_sour', (string) $stat_sour);
273        $this->setBlockSetting($block_id, 'stat_other', (string) $stat_other);
274        $this->setBlockSetting($block_id, 'stat_media', (string) $stat_media);
275        $this->setBlockSetting($block_id, 'stat_repo', (string) $stat_repo);
276        $this->setBlockSetting($block_id, 'stat_surname', (string) $stat_surname);
277        $this->setBlockSetting($block_id, 'stat_events', (string) $stat_events);
278        $this->setBlockSetting($block_id, 'stat_users', (string) $stat_users);
279        $this->setBlockSetting($block_id, 'stat_first_birth', (string) $stat_first_birth);
280        $this->setBlockSetting($block_id, 'stat_last_birth', (string) $stat_last_birth);
281        $this->setBlockSetting($block_id, 'stat_first_death', (string) $stat_first_death);
282        $this->setBlockSetting($block_id, 'stat_last_death', (string) $stat_last_death);
283        $this->setBlockSetting($block_id, 'stat_long_life', (string) $stat_long_life);
284        $this->setBlockSetting($block_id, 'stat_avg_life', (string) $stat_avg_life);
285        $this->setBlockSetting($block_id, 'stat_most_chil', (string) $stat_most_chil);
286        $this->setBlockSetting($block_id, 'stat_avg_chil', (string) $stat_avg_chil);
287    }
288
289    /**
290     * An HTML form to edit block settings
291     *
292     * @param Tree $tree
293     * @param int  $block_id
294     *
295     * @return string
296     */
297    public function editBlockConfiguration(Tree $tree, int $block_id): string
298    {
299        $show_last_update     = $this->getBlockSetting($block_id, 'show_last_update', '1');
300        $show_common_surnames = $this->getBlockSetting($block_id, 'show_common_surnames', '1');
301        $number_of_surnames   = $this->getBlockSetting($block_id, 'number_of_surnames', self::DEFAULT_NUMBER_OF_SURNAMES);
302        $stat_indi            = $this->getBlockSetting($block_id, 'stat_indi', '1');
303        $stat_fam             = $this->getBlockSetting($block_id, 'stat_fam', '1');
304        $stat_sour            = $this->getBlockSetting($block_id, 'stat_sour', '1');
305        $stat_media           = $this->getBlockSetting($block_id, 'stat_media', '1');
306        $stat_repo            = $this->getBlockSetting($block_id, 'stat_repo', '1');
307        $stat_surname         = $this->getBlockSetting($block_id, 'stat_surname', '1');
308        $stat_events          = $this->getBlockSetting($block_id, 'stat_events', '1');
309        $stat_users           = $this->getBlockSetting($block_id, 'stat_users', '1');
310        $stat_first_birth     = $this->getBlockSetting($block_id, 'stat_first_birth', '1');
311        $stat_last_birth      = $this->getBlockSetting($block_id, 'stat_last_birth', '1');
312        $stat_first_death     = $this->getBlockSetting($block_id, 'stat_first_death', '1');
313        $stat_last_death      = $this->getBlockSetting($block_id, 'stat_last_death', '1');
314        $stat_long_life       = $this->getBlockSetting($block_id, 'stat_long_life', '1');
315        $stat_avg_life        = $this->getBlockSetting($block_id, 'stat_avg_life', '1');
316        $stat_most_chil       = $this->getBlockSetting($block_id, 'stat_most_chil', '1');
317        $stat_avg_chil        = $this->getBlockSetting($block_id, 'stat_avg_chil', '1');
318
319        return view('modules/gedcom_stats/config', [
320            'show_last_update'     => $show_last_update,
321            'show_common_surnames' => $show_common_surnames,
322            'number_of_surnames'   => $number_of_surnames,
323            'stat_indi'            => $stat_indi,
324            'stat_fam'             => $stat_fam,
325            'stat_sour'            => $stat_sour,
326            'stat_media'           => $stat_media,
327            'stat_repo'            => $stat_repo,
328            'stat_surname'         => $stat_surname,
329            'stat_events'          => $stat_events,
330            'stat_users'           => $stat_users,
331            'stat_first_birth'     => $stat_first_birth,
332            'stat_last_birth'      => $stat_last_birth,
333            'stat_first_death'     => $stat_first_death,
334            'stat_last_death'      => $stat_last_death,
335            'stat_long_life'       => $stat_long_life,
336            'stat_avg_life'        => $stat_avg_life,
337            'stat_most_chil'       => $stat_most_chil,
338            'stat_avg_chil'        => $stat_avg_chil,
339        ]);
340    }
341
342    /**
343     * This module assumes the database will use binary collation on the name columns.
344     * Until we convert MySQL databases to use utf8_bin, we need to do this at run-time.
345     *
346     * @param string      $column
347     * @param string|null $alias
348     *
349     * @return Expression
350     */
351    private function binaryColumn(string $column, string $alias = null): Expression
352    {
353        if (DB::connection()->getDriverName() === 'mysql') {
354            $sql = 'CAST(' . $column . ' AS binary)';
355        } else {
356            $sql = $column;
357        }
358
359        if ($alias !== null) {
360            $sql .= ' AS ' . $alias;
361        }
362
363        return new Expression($sql);
364    }
365}
366