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