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