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\Services; 21 22use DateInterval; 23use DateTimeImmutable; 24use DateTimeZone; 25use Fisharebest\Webtrees\Auth; 26use Fisharebest\Webtrees\Contracts\UserInterface; 27use Fisharebest\Webtrees\DB; 28use Fisharebest\Webtrees\Exceptions\GedcomErrorException; 29use Fisharebest\Webtrees\Family; 30use Fisharebest\Webtrees\Gedcom; 31use Fisharebest\Webtrees\GedcomRecord; 32use Fisharebest\Webtrees\Header; 33use Fisharebest\Webtrees\Individual; 34use Fisharebest\Webtrees\Location; 35use Fisharebest\Webtrees\Media; 36use Fisharebest\Webtrees\Note; 37use Fisharebest\Webtrees\Registry; 38use Fisharebest\Webtrees\Repository; 39use Fisharebest\Webtrees\Source; 40use Fisharebest\Webtrees\Submission; 41use Fisharebest\Webtrees\Submitter; 42use Fisharebest\Webtrees\Tree; 43use Illuminate\Database\Query\Builder; 44use Illuminate\Database\Query\Expression; 45use Illuminate\Support\Collection; 46 47use function addcslashes; 48use function preg_match; 49 50/** 51 * Manage pending changes 52 */ 53class PendingChangesService 54{ 55 private GedcomImportService $gedcom_import_service; 56 57 /** 58 * @param GedcomImportService $gedcom_import_service 59 */ 60 public function __construct(GedcomImportService $gedcom_import_service) 61 { 62 $this->gedcom_import_service = $gedcom_import_service; 63 } 64 65 /** 66 * Which records have pending changes 67 * 68 * @param Tree $tree 69 * 70 * @return Collection<int,string> 71 */ 72 public function pendingXrefs(Tree $tree): Collection 73 { 74 return DB::table('change') 75 ->where('status', '=', 'pending') 76 ->where('gedcom_id', '=', $tree->id()) 77 ->orderBy('xref') 78 ->groupBy(['xref']) 79 ->pluck('xref'); 80 } 81 82 /** 83 * @param Tree $tree 84 * @param int $n 85 * 86 * @return array<array<object>> 87 */ 88 public function pendingChanges(Tree $tree, int $n): array 89 { 90 $xrefs = $this->pendingXrefs($tree); 91 92 $rows = DB::table('change') 93 ->join('user', 'user.user_id', '=', 'change.user_id') 94 ->where('status', '=', 'pending') 95 ->where('gedcom_id', '=', $tree->id()) 96 ->whereIn('xref', $xrefs->slice(0, $n)) 97 ->orderBy('change.change_id') 98 ->select(['change.*', 'user.user_name', 'user.real_name']) 99 ->get(); 100 101 $changes = []; 102 103 $factories = [ 104 Individual::RECORD_TYPE => Registry::individualFactory(), 105 Family::RECORD_TYPE => Registry::familyFactory(), 106 Source::RECORD_TYPE => Registry::sourceFactory(), 107 Repository::RECORD_TYPE => Registry::repositoryFactory(), 108 Media::RECORD_TYPE => Registry::mediaFactory(), 109 Note::RECORD_TYPE => Registry::noteFactory(), 110 Submitter::RECORD_TYPE => Registry::submitterFactory(), 111 Submission::RECORD_TYPE => Registry::submissionFactory(), 112 Location::RECORD_TYPE => Registry::locationFactory(), 113 Header::RECORD_TYPE => Registry::headerFactory(), 114 ]; 115 116 foreach ($rows as $row) { 117 $row->change_time = Registry::timestampFactory()->fromString($row->change_time); 118 119 preg_match('/^0 (?:@' . Gedcom::REGEX_XREF . '@ )?(' . Gedcom::REGEX_TAG . ')/', $row->old_gedcom . $row->new_gedcom, $match); 120 121 $factory = $factories[$match[1]] ?? Registry::gedcomRecordFactory(); 122 123 $row->record = $factory->new($row->xref, $row->old_gedcom, $row->new_gedcom, $tree); 124 125 $changes[$row->xref][] = $row; 126 } 127 128 return $changes; 129 } 130 131 /** 132 * Accept all changes to a tree. 133 * 134 * @param Tree $tree 135 * 136 * @param int $n 137 * 138 * @return void 139 * @throws GedcomErrorException 140 */ 141 public function acceptTree(Tree $tree, int $n): void 142 { 143 $xrefs = $this->pendingXrefs($tree); 144 145 $changes = DB::table('change') 146 ->where('gedcom_id', '=', $tree->id()) 147 ->where('status', '=', 'pending') 148 ->whereIn('xref', $xrefs->slice(0, $n)) 149 ->orderBy('change_id') 150 ->lockForUpdate() 151 ->get(); 152 153 foreach ($changes as $change) { 154 if ($change->new_gedcom === '') { 155 // delete 156 $this->gedcom_import_service->updateRecord($change->old_gedcom, $tree, true); 157 } else { 158 // add/update 159 $this->gedcom_import_service->updateRecord($change->new_gedcom, $tree, false); 160 } 161 162 DB::table('change') 163 ->where('change_id', '=', $change->change_id) 164 ->update(['status' => 'accepted']); 165 } 166 } 167 168 /** 169 * Accept all changes to a record. 170 * 171 * @param GedcomRecord $record 172 */ 173 public function acceptRecord(GedcomRecord $record): void 174 { 175 $changes = DB::table('change') 176 ->where('gedcom_id', '=', $record->tree()->id()) 177 ->where('xref', '=', $record->xref()) 178 ->where('status', '=', 'pending') 179 ->orderBy('change_id') 180 ->lockForUpdate() 181 ->get(); 182 183 foreach ($changes as $change) { 184 if ($change->new_gedcom === '') { 185 // delete 186 $this->gedcom_import_service->updateRecord($change->old_gedcom, $record->tree(), true); 187 } else { 188 // add/update 189 $this->gedcom_import_service->updateRecord($change->new_gedcom, $record->tree(), false); 190 } 191 192 DB::table('change') 193 ->where('change_id', '=', $change->change_id) 194 ->update(['status' => 'accepted']); 195 } 196 } 197 198 /** 199 * Accept a change (and previous changes) to a record. 200 * 201 * @param GedcomRecord $record 202 * @param string $change_id 203 */ 204 public function acceptChange(GedcomRecord $record, string $change_id): void 205 { 206 $changes = DB::table('change') 207 ->where('gedcom_id', '=', $record->tree()->id()) 208 ->where('xref', '=', $record->xref()) 209 ->where('change_id', '<=', $change_id) 210 ->where('status', '=', 'pending') 211 ->orderBy('change_id') 212 ->get(); 213 214 foreach ($changes as $change) { 215 if ($change->new_gedcom === '') { 216 // delete 217 $this->gedcom_import_service->updateRecord($change->old_gedcom, $record->tree(), true); 218 } else { 219 // add/update 220 $this->gedcom_import_service->updateRecord($change->new_gedcom, $record->tree(), false); 221 } 222 223 DB::table('change') 224 ->where('change_id', '=', $change->change_id) 225 ->update(['status' => 'accepted']); 226 } 227 } 228 229 /** 230 * Reject all changes to a tree. 231 * 232 * @param Tree $tree 233 */ 234 public function rejectTree(Tree $tree): void 235 { 236 DB::table('change') 237 ->where('gedcom_id', '=', $tree->id()) 238 ->where('status', '=', 'pending') 239 ->update(['status' => 'rejected']); 240 } 241 242 /** 243 * Reject a change (subsequent changes) to a record. 244 * 245 * @param GedcomRecord $record 246 * @param string $change_id 247 */ 248 public function rejectChange(GedcomRecord $record, string $change_id): void 249 { 250 DB::table('change') 251 ->where('gedcom_id', '=', $record->tree()->id()) 252 ->where('xref', '=', $record->xref()) 253 ->where('change_id', '>=', $change_id) 254 ->where('status', '=', 'pending') 255 ->update(['status' => 'rejected']); 256 } 257 258 /** 259 * Reject all changes to a record. 260 * 261 * @param GedcomRecord $record 262 */ 263 public function rejectRecord(GedcomRecord $record): void 264 { 265 DB::table('change') 266 ->where('gedcom_id', '=', $record->tree()->id()) 267 ->where('xref', '=', $record->xref()) 268 ->where('status', '=', 'pending') 269 ->update(['status' => 'rejected']); 270 } 271 272 /** 273 * Generate a query for filtering the changes log. 274 * 275 * @param array<string> $params 276 * 277 * @return Builder 278 */ 279 public function changesQuery(array $params): Builder 280 { 281 $tree = $params['tree']; 282 $from = $params['from'] ?? ''; 283 $to = $params['to'] ?? ''; 284 $type = $params['type'] ?? ''; 285 $oldged = $params['oldged'] ?? ''; 286 $newged = $params['newged'] ?? ''; 287 $xref = $params['xref'] ?? ''; 288 $username = $params['username'] ?? ''; 289 290 $query = DB::table('change') 291 ->leftJoin('user', 'user.user_id', '=', 'change.user_id') 292 ->join('gedcom', 'gedcom.gedcom_id', '=', 'change.gedcom_id') 293 ->select(['change.*', new Expression("COALESCE(user_name, '<none>') AS user_name"), 'gedcom_name']) 294 ->where('gedcom_name', '=', $tree); 295 296 $tz = new DateTimeZone(Auth::user()->getPreference(UserInterface::PREF_TIME_ZONE, 'UTC')); 297 $utc = new DateTimeZone('UTC'); 298 299 if ($from !== '') { 300 $from_time = DateTimeImmutable::createFromFormat('!Y-m-d', $from, $tz) 301 ->setTimezone($utc) 302 ->format('Y-m-d H:i:s'); 303 304 $query->where('change_time', '>=', $from_time); 305 } 306 307 if ($to !== '') { 308 // before end of the day 309 $to_time = DateTimeImmutable::createFromFormat('!Y-m-d', $to, $tz) 310 ->add(new DateInterval('P1D')) 311 ->setTimezone($utc) 312 ->format('Y-m-d H:i:s'); 313 314 $query->where('change_time', '<', $to_time); 315 } 316 317 if ($type !== '') { 318 $query->where('status', '=', $type); 319 } 320 321 if ($oldged !== '') { 322 $query->where('old_gedcom', 'LIKE', '%' . addcslashes($oldged, '\\%_') . '%'); 323 } 324 if ($newged !== '') { 325 $query->where('new_gedcom', 'LIKE', '%' . addcslashes($newged, '\\%_') . '%'); 326 } 327 328 if ($xref !== '') { 329 $query->where('xref', '=', $xref); 330 } 331 332 if ($username !== '') { 333 $query->where('user_name', 'LIKE', '%' . addcslashes($username, '\\%_') . '%'); 334 } 335 336 return $query; 337 } 338} 339