xref: /webtrees/app/Services/TreeService.php (revision be2b3f4df0e459b29aadff26be27e15c8ee9b3b4)
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 DomainException;
23use Fisharebest\Webtrees\Auth;
24use Fisharebest\Webtrees\Contracts\UserInterface;
25use Fisharebest\Webtrees\DB;
26use Fisharebest\Webtrees\GedcomFilters\GedcomEncodingFilter;
27use Fisharebest\Webtrees\I18N;
28use Fisharebest\Webtrees\Registry;
29use Fisharebest\Webtrees\Site;
30use Fisharebest\Webtrees\Tree;
31use Illuminate\Database\Query\Builder;
32use Illuminate\Database\Query\Expression;
33use Illuminate\Database\Query\JoinClause;
34use Illuminate\Support\Collection;
35use Psr\Http\Message\StreamInterface;
36
37use function fclose;
38use function feof;
39use function fread;
40use function max;
41use function stream_filter_append;
42use function strrpos;
43use function substr;
44
45use const STREAM_FILTER_READ;
46
47/**
48 * Tree management and queries.
49 */
50class TreeService
51{
52    // The most likely surname tradition for a given language.
53    private const DEFAULT_SURNAME_TRADITIONS = [
54        'es'    => 'spanish',
55        'is'    => 'icelandic',
56        'lt'    => 'lithuanian',
57        'pl'    => 'polish',
58        'pt'    => 'portuguese',
59        'pt-BR' => 'portuguese',
60    ];
61
62    private GedcomImportService $gedcom_import_service;
63
64    /**
65     * @param GedcomImportService $gedcom_import_service
66     */
67    public function __construct(GedcomImportService $gedcom_import_service)
68    {
69        $this->gedcom_import_service = $gedcom_import_service;
70    }
71
72    /**
73     * All the trees that the current user has permission to access.
74     *
75     * @return Collection<array-key,Tree>
76     */
77    public function all(): Collection
78    {
79        return Registry::cache()->array()->remember('all-trees', static function (): Collection {
80            // All trees
81            $query = DB::table('gedcom')
82                ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
83                    $join->on('gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
84                        ->where('gedcom_setting.setting_name', '=', 'title');
85                })
86                ->where('gedcom.gedcom_id', '>', 0)
87                ->select([
88                    'gedcom.gedcom_id AS tree_id',
89                    'gedcom.gedcom_name AS tree_name',
90                    'gedcom_setting.setting_value AS tree_title',
91                ])
92                ->orderBy('gedcom.sort_order')
93                ->orderBy('gedcom_setting.setting_value');
94
95            // Non-admins may not see all trees
96            if (!Auth::isAdmin()) {
97                $query
98                    ->join('gedcom_setting AS gs2', static function (JoinClause $join): void {
99                        $join
100                            ->on('gs2.gedcom_id', '=', 'gedcom.gedcom_id')
101                            ->where('gs2.setting_name', '=', 'imported');
102                    })
103                    ->join('gedcom_setting AS gs3', static function (JoinClause $join): void {
104                        $join
105                            ->on('gs3.gedcom_id', '=', 'gedcom.gedcom_id')
106                            ->where('gs3.setting_name', '=', 'REQUIRE_AUTHENTICATION');
107                    })
108                    ->leftJoin('user_gedcom_setting', static function (JoinClause $join): void {
109                        $join
110                            ->on('user_gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
111                            ->where('user_gedcom_setting.user_id', '=', Auth::id())
112                            ->where('user_gedcom_setting.setting_name', '=', UserInterface::PREF_TREE_ROLE);
113                    })
114                    ->where(static function (Builder $query): void {
115                        $query
116                            // Managers
117                            ->where('user_gedcom_setting.setting_value', '=', UserInterface::ROLE_MANAGER)
118                            // Members
119                            ->orWhere(static function (Builder $query): void {
120                                $query
121                                    ->where('gs2.setting_value', '=', '1')
122                                    ->where('gs3.setting_value', '=', '1')
123                                    ->where('user_gedcom_setting.setting_value', '<>', UserInterface::ROLE_VISITOR);
124                            })
125                            // Public trees
126                            ->orWhere(static function (Builder $query): void {
127                                $query
128                                    ->where('gs2.setting_value', '=', '1')
129                                    ->where('gs3.setting_value', '<>', '1');
130                            });
131                    });
132            }
133
134            return $query
135                ->get()
136                ->mapWithKeys(static function (object $row): array {
137                    return [$row->tree_name => Tree::rowMapper()($row)];
138                });
139        });
140    }
141
142    /**
143     * Find a tree by its ID.
144     *
145     * @param int $id
146     *
147     * @return Tree
148     */
149    public function find(int $id): Tree
150    {
151        $tree = $this->all()->first(static function (Tree $tree) use ($id): bool {
152            return $tree->id() === $id;
153        });
154
155        if ($tree instanceof Tree) {
156            return $tree;
157        }
158
159        throw new DomainException('Call to find() with an invalid id: ' . $id);
160    }
161
162    /**
163     * All trees, name => title
164     *
165     * @return array<string>
166     */
167    public function titles(): array
168    {
169        return $this->all()->map(static function (Tree $tree): string {
170            return $tree->title();
171        })->all();
172    }
173
174    /**
175     * @param string $name
176     * @param string $title
177     *
178     * @return Tree
179     */
180    public function create(string $name, string $title): Tree
181    {
182        DB::table('gedcom')->insert([
183            'gedcom_name' => $name,
184        ]);
185
186        $tree_id = (int) DB::connection()->getPdo()->lastInsertId();
187
188        $tree = new Tree($tree_id, $name, $title);
189
190        $tree->setPreference('imported', '1');
191        $tree->setPreference('title', $title);
192
193        // Set preferences from default tree
194        (new Builder(DB::connection()))->from('gedcom_setting')->insertUsing(
195            ['gedcom_id', 'setting_name', 'setting_value'],
196            static function (Builder $query) use ($tree_id): void {
197                $query
198                    ->select([new Expression($tree_id), 'setting_name', 'setting_value'])
199                    ->from('gedcom_setting')
200                    ->where('gedcom_id', '=', -1);
201            }
202        );
203
204        (new Builder(DB::connection()))->from('default_resn')->insertUsing(
205            ['gedcom_id', 'tag_type', 'resn'],
206            static function (Builder $query) use ($tree_id): void {
207                $query
208                    ->select([new Expression($tree_id), 'tag_type', 'resn'])
209                    ->from('default_resn')
210                    ->where('gedcom_id', '=', -1);
211            }
212        );
213
214        // Gedcom and privacy settings
215        $tree->setPreference('REQUIRE_AUTHENTICATION', '');
216        $tree->setPreference('CONTACT_USER_ID', (string) Auth::id());
217        $tree->setPreference('WEBMASTER_USER_ID', (string) Auth::id());
218        $tree->setPreference('LANGUAGE', I18N::languageTag()); // Default to the current admin’s language
219        $tree->setPreference('SURNAME_TRADITION', self::DEFAULT_SURNAME_TRADITIONS[I18N::languageTag()] ?? 'paternal');
220
221        // A tree needs at least one record.
222        $head = "0 HEAD\n1 SOUR webtrees\n1 DEST webtrees\n1 GEDC\n2 VERS 5.5.1\n2 FORM LINEAGE-LINKED\n1 CHAR UTF-8";
223        $this->gedcom_import_service->importRecord($head, $tree, true);
224
225        // I18N: This should be a common/default/placeholder name of an individual. Put slashes around the surname.
226        $name = I18N::translate('John /DOE/');
227        $note = I18N::translate('Edit this individual and replace their details with your own.');
228        $indi = "0 @X1@ INDI\n1 NAME " . $name . "\n1 SEX M\n1 BIRT\n2 DATE 01 JAN 1850\n2 NOTE " . $note;
229        $this->gedcom_import_service->importRecord($indi, $tree, true);
230
231        return $tree;
232    }
233
234    /**
235     * Import data from a gedcom file into this tree.
236     *
237     * @param Tree            $tree
238     * @param StreamInterface $stream   The GEDCOM file.
239     * @param string          $filename The preferred filename, for export/download.
240     * @param string          $encoding Override the encoding specified in the header.
241     *
242     * @return void
243     */
244    public function importGedcomFile(Tree $tree, StreamInterface $stream, string $filename, string $encoding): void
245    {
246        // Read the file in blocks of roughly 64K. Ensure that each block
247        // contains complete gedcom records. This will ensure we don’t split
248        // multi-byte characters, as well as simplifying the code to import
249        // each block.
250
251        $file_data = '';
252
253        $tree->setPreference('gedcom_filename', $filename);
254        $tree->setPreference('imported', '0');
255
256        DB::table('gedcom_chunk')->where('gedcom_id', '=', $tree->id())->delete();
257
258        $stream = $stream->detach();
259
260        // Convert to UTF-8.
261        stream_filter_append($stream, GedcomEncodingFilter::class, STREAM_FILTER_READ, ['src_encoding' => $encoding]);
262
263        while (!feof($stream)) {
264            $file_data .= fread($stream, 65536);
265            $eol_pos = max((int) strrpos($file_data, "\r0"), (int) strrpos($file_data, "\n0"));
266
267            if ($eol_pos > 0) {
268                DB::table('gedcom_chunk')->insert([
269                    'gedcom_id'  => $tree->id(),
270                    'chunk_data' => substr($file_data, 0, $eol_pos + 1),
271                ]);
272
273                $file_data = substr($file_data, $eol_pos + 1);
274            }
275        }
276
277        DB::table('gedcom_chunk')->insert([
278            'gedcom_id'  => $tree->id(),
279            'chunk_data' => $file_data,
280        ]);
281
282        fclose($stream);
283    }
284
285    /**
286     * @param Tree $tree
287     */
288    public function delete(Tree $tree): void
289    {
290        // If this is the default tree, then unset it
291        if (Site::getPreference('DEFAULT_GEDCOM') === $tree->name()) {
292            Site::setPreference('DEFAULT_GEDCOM', '');
293        }
294
295        DB::table('gedcom_chunk')->where('gedcom_id', '=', $tree->id())->delete();
296        DB::table('individuals')->where('i_file', '=', $tree->id())->delete();
297        DB::table('families')->where('f_file', '=', $tree->id())->delete();
298        DB::table('sources')->where('s_file', '=', $tree->id())->delete();
299        DB::table('other')->where('o_file', '=', $tree->id())->delete();
300        DB::table('places')->where('p_file', '=', $tree->id())->delete();
301        DB::table('placelinks')->where('pl_file', '=', $tree->id())->delete();
302        DB::table('name')->where('n_file', '=', $tree->id())->delete();
303        DB::table('dates')->where('d_file', '=', $tree->id())->delete();
304        DB::table('change')->where('gedcom_id', '=', $tree->id())->delete();
305        DB::table('link')->where('l_file', '=', $tree->id())->delete();
306        DB::table('media_file')->where('m_file', '=', $tree->id())->delete();
307        DB::table('media')->where('m_file', '=', $tree->id())->delete();
308        DB::table('block_setting')
309            ->join('block', 'block.block_id', '=', 'block_setting.block_id')
310            ->where('gedcom_id', '=', $tree->id())
311            ->delete();
312        DB::table('block')->where('gedcom_id', '=', $tree->id())->delete();
313        DB::table('user_gedcom_setting')->where('gedcom_id', '=', $tree->id())->delete();
314        DB::table('gedcom_setting')->where('gedcom_id', '=', $tree->id())->delete();
315        DB::table('module_privacy')->where('gedcom_id', '=', $tree->id())->delete();
316        DB::table('hit_counter')->where('gedcom_id', '=', $tree->id())->delete();
317        DB::table('default_resn')->where('gedcom_id', '=', $tree->id())->delete();
318        DB::table('gedcom_chunk')->where('gedcom_id', '=', $tree->id())->delete();
319        DB::table('log')->where('gedcom_id', '=', $tree->id())->delete();
320        DB::table('gedcom')->where('gedcom_id', '=', $tree->id())->delete();
321    }
322
323    /**
324     * Generate a unique name for a new tree.
325     *
326     * @return string
327     */
328    public function uniqueTreeName(): string
329    {
330        $name   = 'tree';
331        $number = 1;
332
333        while ($this->all()->get($name . $number) instanceof Tree) {
334            $number++;
335        }
336
337        return $name . $number;
338    }
339}
340