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