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; 21 22use Fisharebest\Webtrees\Module\ModuleInterface; 23use Fisharebest\Webtrees\Module\ModuleListInterface; 24use Fisharebest\Webtrees\Module\PlaceHierarchyListModule; 25use Fisharebest\Webtrees\Services\ModuleService; 26use Illuminate\Database\Capsule\Manager as DB; 27use Illuminate\Support\Collection; 28 29use function e; 30use function is_object; 31use function preg_split; 32use function strip_tags; 33use function trim; 34 35use const PREG_SPLIT_NO_EMPTY; 36 37/** 38 * A GEDCOM place (PLAC) object. 39 */ 40class Place 41{ 42 // "Westminster, London, England" 43 private string $place_name; 44 45 /** @var Collection<int,string> The parts of a place name, e.g. ["Westminster", "London", "England"] */ 46 private Collection $parts; 47 48 private Tree $tree; 49 50 /** 51 * Create a place. 52 * 53 * @param string $place_name 54 * @param Tree $tree 55 */ 56 public function __construct(string $place_name, Tree $tree) 57 { 58 // Ignore any empty parts in place names such as "Village, , , Country". 59 $place_name = trim($place_name); 60 $this->parts = new Collection(preg_split(Gedcom::PLACE_SEPARATOR_REGEX, $place_name, -1, PREG_SPLIT_NO_EMPTY)); 61 62 // Rebuild the placename in the correct format. 63 $this->place_name = $this->parts->implode(Gedcom::PLACE_SEPARATOR); 64 65 $this->tree = $tree; 66 } 67 68 /** 69 * Find a place by its ID. 70 * 71 * @param int $id 72 * @param Tree $tree 73 * 74 * @return Place 75 */ 76 public static function find(int $id, Tree $tree): Place 77 { 78 $parts = new Collection(); 79 80 while ($id !== 0) { 81 $row = DB::table('places') 82 ->where('p_file', '=', $tree->id()) 83 ->where('p_id', '=', $id) 84 ->first(); 85 86 if (is_object($row)) { 87 $id = (int) $row->p_parent_id; 88 $parts->add($row->p_place); 89 } else { 90 $id = 0; 91 } 92 } 93 94 $place_name = $parts->implode(Gedcom::PLACE_SEPARATOR); 95 96 return new Place($place_name, $tree); 97 } 98 99 /** 100 * Get the higher level place. 101 * 102 * @return Place 103 */ 104 public function parent(): Place 105 { 106 return new self($this->parts->slice(1)->implode(Gedcom::PLACE_SEPARATOR), $this->tree); 107 } 108 109 /** 110 * The database row that contains this place. 111 * Note that due to database collation, both "Quebec" and "Québec" will share the same row. 112 * 113 * @return int 114 */ 115 public function id(): int 116 { 117 return Registry::cache()->array()->remember('place-' . $this->place_name, function (): int { 118 // The "top-level" place won't exist in the database. 119 if ($this->parts->isEmpty()) { 120 return 0; 121 } 122 123 $parent_place_id = $this->parent()->id(); 124 125 $place_id = (int) DB::table('places') 126 ->where('p_file', '=', $this->tree->id()) 127 ->where('p_place', '=', mb_substr($this->parts->first(), 0, 120)) 128 ->where('p_parent_id', '=', $parent_place_id) 129 ->value('p_id'); 130 131 if ($place_id === 0) { 132 $place = $this->parts->first(); 133 134 DB::table('places')->insert([ 135 'p_file' => $this->tree->id(), 136 'p_place' => mb_substr($place, 0, 120), 137 'p_parent_id' => $parent_place_id, 138 'p_std_soundex' => Soundex::russell($place), 139 'p_dm_soundex' => Soundex::daitchMokotoff($place), 140 ]); 141 142 $place_id = (int) DB::connection()->getPdo()->lastInsertId(); 143 } 144 145 return $place_id; 146 }); 147 } 148 149 /** 150 * @return Tree 151 */ 152 public function tree(): Tree 153 { 154 return $this->tree; 155 } 156 157 /** 158 * Extract the locality (first parts) of a place name. 159 * 160 * @param int $n 161 * 162 * @return Collection<int,string> 163 */ 164 public function firstParts(int $n): Collection 165 { 166 return $this->parts->slice(0, $n); 167 } 168 169 /** 170 * Extract the country (last parts) of a place name. 171 * 172 * @param int $n 173 * 174 * @return Collection<int,string> 175 */ 176 public function lastParts(int $n): Collection 177 { 178 return $this->parts->slice(-$n); 179 } 180 181 /** 182 * Get the lower level places. 183 * 184 * @return array<Place> 185 */ 186 public function getChildPlaces(): array 187 { 188 if ($this->place_name !== '') { 189 $parent_text = Gedcom::PLACE_SEPARATOR . $this->place_name; 190 } else { 191 $parent_text = ''; 192 } 193 194 return DB::table('places') 195 ->where('p_file', '=', $this->tree->id()) 196 ->where('p_parent_id', '=', $this->id()) 197 ->pluck('p_place') 198 ->sort(I18N::comparator()) 199 ->map(function (string $place) use ($parent_text): Place { 200 return new self($place . $parent_text, $this->tree); 201 }) 202 ->all(); 203 } 204 205 /** 206 * Create a URL to the place-hierarchy page. 207 * 208 * @return string 209 */ 210 public function url(): string 211 { 212 //find a module providing the place hierarchy 213 $module = Registry::container()->get(ModuleService::class) 214 ->findByComponent(ModuleListInterface::class, $this->tree, Auth::user()) 215 ->first(static function (ModuleInterface $module): bool { 216 return $module instanceof PlaceHierarchyListModule; 217 }); 218 219 if ($module instanceof PlaceHierarchyListModule) { 220 return $module->listUrl($this->tree, [ 221 'place_id' => $this->id(), 222 'tree' => $this->tree->name(), 223 ]); 224 } 225 226 // The place-list module is disabled... 227 return '#'; 228 } 229 230 /** 231 * Format this place for GEDCOM data. 232 * 233 * @return string 234 */ 235 public function gedcomName(): string 236 { 237 return $this->place_name; 238 } 239 240 /** 241 * Format this place for display on screen. 242 * 243 * @return string 244 */ 245 public function placeName(): string 246 { 247 $place_name = $this->parts->first() ?? I18N::translate('unknown'); 248 249 return '<bdi>' . e($place_name) . '</bdi>'; 250 } 251 252 /** 253 * Generate the place name for display, including the full hierarchy. 254 * 255 * @param bool $link 256 * 257 * @return string 258 */ 259 public function fullName(bool $link = false): string 260 { 261 if ($this->parts->isEmpty()) { 262 return ''; 263 } 264 265 $full_name = $this->parts->implode(I18N::$list_separator); 266 267 if ($link) { 268 return '<a dir="auto" href="' . e($this->url()) . '">' . e($full_name) . '</a>'; 269 } 270 271 return '<bdi>' . e($full_name) . '</bdi>'; 272 } 273 274 /** 275 * For lists and charts, where the full name won’t fit. 276 * 277 * @param bool $link 278 * 279 * @return string 280 */ 281 public function shortName(bool $link = false): string 282 { 283 $SHOW_PEDIGREE_PLACES = (int) $this->tree->getPreference('SHOW_PEDIGREE_PLACES'); 284 285 // Abbreviate the place name, for lists 286 if ($this->tree->getPreference('SHOW_PEDIGREE_PLACES_SUFFIX')) { 287 $parts = $this->lastParts($SHOW_PEDIGREE_PLACES); 288 } else { 289 $parts = $this->firstParts($SHOW_PEDIGREE_PLACES); 290 } 291 292 $short_name = $parts->implode(I18N::$list_separator); 293 294 // Add a tool-tip showing the full name 295 $title = strip_tags($this->fullName()); 296 297 if ($link) { 298 return '<a dir="auto" href="' . e($this->url()) . '" title="' . $title . '">' . e($short_name) . '</a>'; 299 } 300 301 return '<bdi>' . e($short_name) . '</bdi>'; 302 } 303} 304