xref: /webtrees/app/Module/RecentChangesModule.php (revision e873f434551745f888937263ff89e80db3b0f785)
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\DB;
23use Fisharebest\Webtrees\Family;
24use Fisharebest\Webtrees\GedcomRecord;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Individual;
27use Fisharebest\Webtrees\Registry;
28use Fisharebest\Webtrees\Services\UserService;
29use Fisharebest\Webtrees\Tree;
30use Fisharebest\Webtrees\User;
31use Fisharebest\Webtrees\Validator;
32use Illuminate\Database\Query\Expression;
33use Illuminate\Database\Query\JoinClause;
34use Illuminate\Support\Collection;
35use Illuminate\Support\Str;
36use Psr\Http\Message\ServerRequestInterface;
37
38use function extract;
39use function view;
40
41use const EXTR_OVERWRITE;
42
43/**
44 * Class RecentChangesModule
45 */
46class RecentChangesModule extends AbstractModule implements ModuleBlockInterface
47{
48    use ModuleBlockTrait;
49
50    // Where do we look for change information
51    private const string SOURCE_DATABASE = 'database';
52    private const string SOURCE_GEDCOM   = 'gedcom';
53
54    private const string DEFAULT_DAYS      = '7';
55    private const string DEFAULT_SHOW_USER = '1';
56    private const string DEFAULT_SHOW_DATE  = '1';
57    private const string DEFAULT_SORT_STYLE = 'date_desc';
58    private const string DEFAULT_INFO_STYLE = 'table';
59    private const string DEFAULT_SOURCE = self::SOURCE_DATABASE;
60    private const int    MAX_DAYS       = 90;
61
62    // Pagination
63    private const int LIMIT_LOW  = 10;
64    private const int LIMIT_HIGH = 20;
65
66    private UserService $user_service;
67
68    /**
69     * @param UserService $user_service
70     */
71    public function __construct(UserService $user_service)
72    {
73        $this->user_service = $user_service;
74    }
75
76    /**
77     * How should this module be identified in the control panel, etc.?
78     *
79     * @return string
80     */
81    public function title(): string
82    {
83        /* I18N: Name of a module */
84        return I18N::translate('Recent changes');
85    }
86
87    public function description(): string
88    {
89        /* I18N: Description of the “Recent changes” module */
90        return I18N::translate('A list of records that have been updated recently.');
91    }
92
93    /**
94     * @param Tree                 $tree
95     * @param int                  $block_id
96     * @param string               $context
97     * @param array<string,string> $config
98     *
99     * @return string
100     */
101    public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string
102    {
103        $days      = (int) $this->getBlockSetting($block_id, 'days', self::DEFAULT_DAYS);
104        $infoStyle = $this->getBlockSetting($block_id, 'infoStyle', self::DEFAULT_INFO_STYLE);
105        $sortStyle = $this->getBlockSetting($block_id, 'sortStyle', self::DEFAULT_SORT_STYLE);
106        $show_user = (bool) $this->getBlockSetting($block_id, 'show_user', self::DEFAULT_SHOW_USER);
107        $show_date = (bool) $this->getBlockSetting($block_id, 'show_date', self::DEFAULT_SHOW_DATE);
108        $source    = $this->getBlockSetting($block_id, 'source', self::DEFAULT_SOURCE);
109
110        extract($config, EXTR_OVERWRITE);
111
112        if ($source === self::SOURCE_DATABASE) {
113            $rows = $this->getRecentChangesFromDatabase($tree, $days);
114        } else {
115            $rows = $this->getRecentChangesFromGenealogy($tree, $days);
116        }
117
118        switch ($sortStyle) {
119            case 'name':
120                $rows  = $rows->sort(static fn (object $x, object $y): int => GedcomRecord::nameComparator()($x->record, $y->record));
121                $order = [[1, 'asc']];
122                break;
123
124            case 'date_asc':
125                $rows  = $rows->sort(static fn (object $x, object $y): int => $x->time <=> $y->time);
126                $order = [[2, 'asc']];
127                break;
128
129            default:
130            case 'date_desc':
131                $rows  = $rows->sort(static fn (object $x, object $y): int => $y->time <=> $x->time);
132                $order = [[2, 'desc']];
133                break;
134        }
135
136        if ($rows->isEmpty()) {
137            $content = I18N::plural('There have been no changes within the last %s day.', 'There have been no changes within the last %s days.', $days, I18N::number($days));
138        } elseif ($infoStyle === 'list') {
139            $content = view('modules/recent_changes/changes-list', [
140                'id'         => $block_id,
141                'limit_low'  => self::LIMIT_LOW,
142                'limit_high' => self::LIMIT_HIGH,
143                'rows'       => $rows->values(),
144                'show_date'  => $show_date,
145                'show_user'  => $show_user,
146            ]);
147        } else {
148            $content = view('modules/recent_changes/changes-table', [
149                'limit_low'  => self::LIMIT_LOW,
150                'limit_high' => self::LIMIT_HIGH,
151                'rows'       => $rows,
152                'show_date'  => $show_date,
153                'show_user'  => $show_user,
154                'order'      => $order,
155            ]);
156        }
157
158        if ($context !== self::CONTEXT_EMBED) {
159            return view('modules/block-template', [
160                'block'      => Str::kebab($this->name()),
161                'id'         => $block_id,
162                'config_url' => $this->configUrl($tree, $context, $block_id),
163                'title'      => I18N::plural('Changes in the last %s day', 'Changes in the last %s days', $days, I18N::number($days)),
164                'content'    => $content,
165            ]);
166        }
167
168        return $content;
169    }
170
171    /**
172     * Should this block load asynchronously using AJAX?
173     *
174     * Simple blocks are faster in-line, more complex ones can be loaded later.
175     *
176     * @return bool
177     */
178    public function loadAjax(): bool
179    {
180        return true;
181    }
182
183    /**
184     * Can this block be shown on the user’s home page?
185     *
186     * @return bool
187     */
188    public function isUserBlock(): bool
189    {
190        return true;
191    }
192
193    /**
194     * Can this block be shown on the tree’s home page?
195     *
196     * @return bool
197     */
198    public function isTreeBlock(): bool
199    {
200        return true;
201    }
202
203    /**
204     * Update the configuration for a block.
205     *
206     * @param ServerRequestInterface $request
207     * @param int                    $block_id
208     *
209     * @return void
210     */
211    public function saveBlockConfiguration(ServerRequestInterface $request, int $block_id): void
212    {
213        $days       = Validator::parsedBody($request)->integer('days');
214        $info_style = Validator::parsedBody($request)->string('infoStyle');
215        $sort_style = Validator::parsedBody($request)->string('sortStyle');
216        $show_date  = Validator::parsedBody($request)->boolean('show_date');
217        $show_user  = Validator::parsedBody($request)->boolean('show_user');
218        $source     = Validator::parsedBody($request)->string('source');
219
220        $this->setBlockSetting($block_id, 'days', (string) $days);
221        $this->setBlockSetting($block_id, 'infoStyle', $info_style);
222        $this->setBlockSetting($block_id, 'sortStyle', $sort_style);
223        $this->setBlockSetting($block_id, 'show_date', (string) $show_date);
224        $this->setBlockSetting($block_id, 'show_user', (string) $show_user);
225        $this->setBlockSetting($block_id, 'source', $source);
226    }
227
228    /**
229     * An HTML form to edit block settings
230     *
231     * @param Tree $tree
232     * @param int  $block_id
233     *
234     * @return string
235     */
236    public function editBlockConfiguration(Tree $tree, int $block_id): string
237    {
238        $days      = (int) $this->getBlockSetting($block_id, 'days', self::DEFAULT_DAYS);
239        $infoStyle = $this->getBlockSetting($block_id, 'infoStyle', self::DEFAULT_INFO_STYLE);
240        $sortStyle = $this->getBlockSetting($block_id, 'sortStyle', self::DEFAULT_SORT_STYLE);
241        $show_date = $this->getBlockSetting($block_id, 'show_date', self::DEFAULT_SHOW_DATE);
242        $show_user = $this->getBlockSetting($block_id, 'show_user', self::DEFAULT_SHOW_USER);
243        $source    = $this->getBlockSetting($block_id, 'source', self::DEFAULT_SOURCE);
244
245        $info_styles = [
246            /* I18N: An option in a list-box */
247            'list'  => I18N::translate('list'),
248            /* I18N: An option in a list-box */
249            'table' => I18N::translate('table'),
250        ];
251
252        $sort_styles = [
253            /* I18N: An option in a list-box */
254            'name'      => I18N::translate('sort by name'),
255            /* I18N: An option in a list-box */
256            'date_asc'  => I18N::translate('sort by date, oldest first'),
257            /* I18N: An option in a list-box */
258            'date_desc' => I18N::translate('sort by date, newest first'),
259        ];
260
261        $sources = [
262            /* I18N: An option in a list-box */
263            self::SOURCE_DATABASE => I18N::translate('show changes made in webtrees'),
264            /* I18N: An option in a list-box */
265            self::SOURCE_GEDCOM   => I18N::translate('show changes recorded in the genealogy data'),
266        ];
267
268        return view('modules/recent_changes/config', [
269            'days'        => $days,
270            'infoStyle'   => $infoStyle,
271            'info_styles' => $info_styles,
272            'max_days'    => self::MAX_DAYS,
273            'sortStyle'   => $sortStyle,
274            'sort_styles' => $sort_styles,
275            'source'      => $source,
276            'sources'     => $sources,
277            'show_date'   => $show_date,
278            'show_user'   => $show_user,
279        ]);
280    }
281
282    /**
283     * Find records that have changed since a given julian day
284     *
285     * @param Tree $tree Changes for which tree
286     * @param int  $days Number of days
287     *
288     * @return Collection<array-key,object> List of records with changes
289     */
290    private function getRecentChangesFromDatabase(Tree $tree, int $days): Collection
291    {
292        $subquery = DB::table('change')
293            ->where('gedcom_id', '=', $tree->id())
294            ->where('status', '=', 'accepted')
295            ->where('new_gedcom', '<>', '')
296            ->where('change_time', '>', Registry::timestampFactory()->now()->subtractDays($days)->toDateTimeString())
297            ->groupBy(['xref'])
298            ->select([new Expression('MAX(change_id) AS recent_change_id')]);
299
300        $query = DB::table('change')
301            ->joinSub($subquery, 'recent', 'recent_change_id', '=', 'change_id')
302            ->select(['change.*']);
303
304        return $query
305            ->get()
306            ->map(fn (object $row): object => (object) [
307                'record' => Registry::gedcomRecordFactory()->make($row->xref, $tree, $row->new_gedcom),
308                'time'   => Registry::timestampFactory()->fromString($row->change_time),
309                'user'   => $this->user_service->find((int) $row->user_id),
310            ])
311            ->filter(static fn (object $row): bool => $row->record instanceof GedcomRecord && $row->record->canShow());
312    }
313
314    /**
315     * Find records that have changed since a given julian day
316     *
317     * @param Tree $tree Changes for which tree
318     * @param int  $days Number of days
319     *
320     * @return Collection<array-key,object> List of records with changes
321     */
322    private function getRecentChangesFromGenealogy(Tree $tree, int $days): Collection
323    {
324        $julian_day = Registry::timestampFactory()->now()->subtractDays($days)->julianDay();
325
326        $individuals = DB::table('dates')
327            ->where('d_file', '=', $tree->id())
328            ->where('d_julianday1', '>=', $julian_day)
329            ->where('d_fact', '=', 'CHAN')
330            ->join('individuals', static function (JoinClause $join): void {
331                $join
332                    ->on('d_file', '=', 'i_file')
333                    ->on('d_gid', '=', 'i_id');
334            })
335            ->select(['individuals.*'])
336            ->get()
337            ->map(Registry::individualFactory()->mapper($tree))
338            ->filter(Individual::accessFilter());
339
340        $families = DB::table('dates')
341            ->where('d_file', '=', $tree->id())
342            ->where('d_julianday1', '>=', $julian_day)
343            ->where('d_fact', '=', 'CHAN')
344            ->join('families', static function (JoinClause $join): void {
345                $join
346                    ->on('d_file', '=', 'f_file')
347                    ->on('d_gid', '=', 'f_id');
348            })
349            ->select(['families.*'])
350            ->get()
351            ->map(Registry::familyFactory()->mapper($tree))
352            ->filter(Family::accessFilter());
353
354        return $individuals->merge($families)
355            ->map(function (GedcomRecord $record): object {
356                $user = $this->user_service->findByUserName($record->lastChangeUser());
357
358                return (object) [
359                    'record' => $record,
360                    'time'   => $record->lastChangeTimestamp(),
361                    'user'   => $user ?? new User(0, '…', '…', ''),
362                ];
363            });
364    }
365}
366