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