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