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\Http\RequestHandlers; 21 22use Exception; 23use Fisharebest\Webtrees\Encodings\UTF8; 24use Fisharebest\Webtrees\Exceptions\GedcomErrorException; 25use Fisharebest\Webtrees\Http\ViewResponseTrait; 26use Fisharebest\Webtrees\I18N; 27use Fisharebest\Webtrees\Services\GedcomImportService; 28use Fisharebest\Webtrees\Services\TimeoutService; 29use Fisharebest\Webtrees\Services\TreeService; 30use Fisharebest\Webtrees\Validator; 31use Illuminate\Database\Capsule\Manager as DB; 32use Illuminate\Database\DetectsConcurrencyErrors; 33use Psr\Http\Message\ResponseInterface; 34use Psr\Http\Message\ServerRequestInterface; 35use Psr\Http\Server\RequestHandlerInterface; 36 37use function preg_split; 38use function response; 39use function str_replace; 40use function str_starts_with; 41use function strlen; 42use function substr; 43use function view; 44 45/** 46 * Load a chunk of GEDCOM data. 47 */ 48class GedcomLoad implements RequestHandlerInterface 49{ 50 use ViewResponseTrait; 51 use DetectsConcurrencyErrors; 52 53 private GedcomImportService $gedcom_import_service; 54 55 private TimeoutService $timeout_service; 56 57 private TreeService $tree_service; 58 59 /** 60 * GedcomLoad constructor. 61 * 62 * @param GedcomImportService $gedcom_import_service 63 * @param TimeoutService $timeout_service 64 * @param TreeService $tree_service 65 */ 66 public function __construct( 67 GedcomImportService $gedcom_import_service, 68 TimeoutService $timeout_service, 69 TreeService $tree_service 70 ) { 71 $this->gedcom_import_service = $gedcom_import_service; 72 $this->timeout_service = $timeout_service; 73 $this->tree_service = $tree_service; 74 } 75 76 /** 77 * @param ServerRequestInterface $request 78 * 79 * @return ResponseInterface 80 */ 81 public function handle(ServerRequestInterface $request): ResponseInterface 82 { 83 $this->layout = 'layouts/ajax'; 84 85 $tree = Validator::attributes($request)->tree(); 86 87 try { 88 // What is the current import status? 89 $import_offset = DB::table('gedcom_chunk') 90 ->where('gedcom_id', '=', $tree->id()) 91 ->where('imported', '=', '1') 92 ->count(); 93 94 $import_total = DB::table('gedcom_chunk') 95 ->where('gedcom_id', '=', $tree->id()) 96 ->count(); 97 98 // Finished? 99 if ($import_offset === $import_total) { 100 if ($tree->getPreference('imported') !== '1') { 101 return $this->viewResponse('admin/import-fail', [ 102 'error' => I18N::translate('Invalid GEDCOM file - no trailer record found.'), 103 'tree' => $tree, 104 ]); 105 } 106 107 return $this->viewResponse('admin/import-complete', ['tree' => $tree]); 108 } 109 110 // Calculate progress so far 111 $progress = $import_offset / $import_total; 112 113 $first_time = $import_offset === 0; 114 115 // Collect up any errors, and show them later. 116 $errors = ''; 117 118 // Run for a short period of time. This keeps the resource requirements low. 119 do { 120 $data = DB::table('gedcom_chunk') 121 ->where('gedcom_id', '=', $tree->id()) 122 ->where('imported', '=', '0') 123 ->orderBy('gedcom_chunk_id') 124 ->select(['gedcom_chunk_id', 'chunk_data']) 125 ->first(); 126 127 if ($data === null) { 128 break; 129 } 130 131 // Mark the chunk as imported. This will create a row-lock, to prevent other 132 // processes from reading it until we have finished. 133 $n = DB::table('gedcom_chunk') 134 ->where('gedcom_chunk_id', '=', $data->gedcom_chunk_id) 135 ->where('imported', '=', '0') 136 ->update(['imported' => 1]); 137 138 // Another process has already imported this data? 139 if ($n === 0) { 140 break; 141 } 142 143 // If we are loading the first (header) record, then delete old data. 144 if ($first_time) { 145 $this->tree_service->deleteGenealogyData($tree, (bool) $tree->getPreference('keep_media')); 146 147 // Remove any byte-order-mark 148 if (str_starts_with($data->chunk_data, UTF8::BYTE_ORDER_MARK)) { 149 $data->chunk_data = substr($data->chunk_data, strlen(UTF8::BYTE_ORDER_MARK)); 150 DB::table('gedcom_chunk') 151 ->where('gedcom_chunk_id', '=', $data->gedcom_chunk_id) 152 ->update(['chunk_data' => $data->chunk_data]); 153 } 154 155 if (!str_starts_with($data->chunk_data, '0 HEAD')) { 156 return $this->viewResponse('admin/import-fail', [ 157 'error' => I18N::translate('Invalid GEDCOM file - no header record found.'), 158 'tree' => $tree, 159 ]); 160 } 161 162 $first_time = false; 163 } 164 165 $data->chunk_data = str_replace("\r", "\n", $data->chunk_data); 166 167 // Import all the records in this chunk of data 168 foreach (preg_split('/\n+(?=0)/', $data->chunk_data) as $rec) { 169 try { 170 $this->gedcom_import_service->importRecord($rec, $tree, false); 171 } catch (GedcomErrorException $exception) { 172 $errors .= $exception->getMessage(); 173 } 174 } 175 176 // Do not need the data any more. 177 DB::table('gedcom_chunk') 178 ->where('gedcom_chunk_id', '=', $data->gedcom_chunk_id) 179 ->update(['chunk_data' => '']); 180 } while (!$this->timeout_service->isTimeLimitUp()); 181 182 return $this->viewResponse('admin/import-progress', [ 183 'errors' => $errors, 184 'progress' => $progress, 185 'tree' => $tree, 186 ]); 187 } catch (Exception $ex) { 188 DB::connection()->rollBack(); 189 190 // Deadlock? Try again. 191 if ($this->causedByConcurrencyError($ex)) { 192 return $this->viewResponse('admin/import-progress', [ 193 'errors' => '', 194 'progress' => $progress ?? 0.0, 195 'tree' => $tree, 196 ]); 197 } 198 199 return $this->viewResponse('admin/import-fail', [ 200 'error' => $ex->getMessage(), 201 'tree' => $tree, 202 ]); 203 } 204 } 205} 206