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