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