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