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