xref: /webtrees/app/Services/TreeService.php (revision 4c96e13d7daf3e97fb6e9d448a6a4532bbd1b36b)
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 fn (object $row): array => [$row->tree_name => Tree::rowMapper()($row)]);
137        });
138    }
139
140    /**
141     * Find a tree by its ID.
142     *
143     * @param int $id
144     *
145     * @return Tree
146     */
147    public function find(int $id): Tree
148    {
149        $tree = $this->all()->first(static fn (Tree $tree): bool => $tree->id() === $id);
150
151        if ($tree instanceof Tree) {
152            return $tree;
153        }
154
155        throw new DomainException('Call to find() with an invalid id: ' . $id);
156    }
157
158    /**
159     * All trees, name => title
160     *
161     * @return array<string>
162     */
163    public function titles(): array
164    {
165        return $this->all()->map(static fn (Tree $tree): string => $tree->title())->all();
166    }
167
168    /**
169     * @param string $name
170     * @param string $title
171     *
172     * @return Tree
173     */
174    public function create(string $name, string $title): Tree
175    {
176        DB::table('gedcom')->insert([
177            'gedcom_name' => $name,
178        ]);
179
180        $tree_id = DB::lastInsertId();
181
182        $tree = new Tree($tree_id, $name, $title);
183
184        $tree->setPreference('imported', '1');
185        $tree->setPreference('title', $title);
186
187        // Set preferences from default tree
188        (new Builder(DB::connection()))->from('gedcom_setting')->insertUsing(
189            ['gedcom_id', 'setting_name', 'setting_value'],
190            static function (Builder $query) use ($tree_id): void {
191                $query
192                    ->select([new Expression($tree_id), 'setting_name', 'setting_value'])
193                    ->from('gedcom_setting')
194                    ->where('gedcom_id', '=', -1);
195            }
196        );
197
198        (new Builder(DB::connection()))->from('default_resn')->insertUsing(
199            ['gedcom_id', 'tag_type', 'resn'],
200            static function (Builder $query) use ($tree_id): void {
201                $query
202                    ->select([new Expression($tree_id), 'tag_type', 'resn'])
203                    ->from('default_resn')
204                    ->where('gedcom_id', '=', -1);
205            }
206        );
207
208        // Gedcom and privacy settings
209        $tree->setPreference('REQUIRE_AUTHENTICATION', '');
210        $tree->setPreference('CONTACT_USER_ID', (string) Auth::id());
211        $tree->setPreference('WEBMASTER_USER_ID', (string) Auth::id());
212        $tree->setPreference('LANGUAGE', I18N::languageTag()); // Default to the current admin’s language
213        $tree->setPreference('SURNAME_TRADITION', self::DEFAULT_SURNAME_TRADITIONS[I18N::languageTag()] ?? 'paternal');
214
215        // A tree needs at least one record.
216        $head = "0 HEAD\n1 SOUR webtrees\n1 DEST webtrees\n1 GEDC\n2 VERS 5.5.1\n2 FORM LINEAGE-LINKED\n1 CHAR UTF-8";
217        $this->gedcom_import_service->importRecord($head, $tree, true);
218
219        // I18N: This should be a common/default/placeholder name of an individual. Put slashes around the surname.
220        $name = I18N::translate('John /DOE/');
221        $note = I18N::translate('Edit this individual and replace their details with your own.');
222        $indi = "0 @X1@ INDI\n1 NAME " . $name . "\n1 SEX M\n1 BIRT\n2 DATE 01 JAN 1850\n2 NOTE " . $note;
223        $this->gedcom_import_service->importRecord($indi, $tree, true);
224
225        return $tree;
226    }
227
228    /**
229     * Import data from a gedcom file into this tree.
230     *
231     * @param Tree            $tree
232     * @param StreamInterface $stream   The GEDCOM file.
233     * @param string          $filename The preferred filename, for export/download.
234     * @param string          $encoding Override the encoding specified in the header.
235     *
236     * @return void
237     */
238    public function importGedcomFile(Tree $tree, StreamInterface $stream, string $filename, string $encoding): void
239    {
240        // Read the file in blocks of roughly 64K. Ensure that each block
241        // contains complete gedcom records. This will ensure we don’t split
242        // multi-byte characters, as well as simplifying the code to import
243        // each block.
244
245        $file_data = '';
246
247        $tree->setPreference('gedcom_filename', $filename);
248        $tree->setPreference('imported', '0');
249
250        DB::table('gedcom_chunk')->where('gedcom_id', '=', $tree->id())->delete();
251
252        $stream = $stream->detach();
253
254        // Convert to UTF-8.
255        stream_filter_append($stream, GedcomEncodingFilter::class, STREAM_FILTER_READ, ['src_encoding' => $encoding]);
256
257        while (!feof($stream)) {
258            $file_data .= fread($stream, 65536);
259            $eol_pos = max((int) strrpos($file_data, "\r0"), (int) strrpos($file_data, "\n0"));
260
261            if ($eol_pos > 0) {
262                DB::table('gedcom_chunk')->insert([
263                    'gedcom_id'  => $tree->id(),
264                    'chunk_data' => substr($file_data, 0, $eol_pos + 1),
265                ]);
266
267                $file_data = substr($file_data, $eol_pos + 1);
268            }
269        }
270
271        DB::table('gedcom_chunk')->insert([
272            'gedcom_id'  => $tree->id(),
273            'chunk_data' => $file_data,
274        ]);
275
276        fclose($stream);
277    }
278
279    /**
280     * @param Tree $tree
281     */
282    public function delete(Tree $tree): void
283    {
284        // If this is the default tree, then unset it
285        if (Site::getPreference('DEFAULT_GEDCOM') === $tree->name()) {
286            Site::setPreference('DEFAULT_GEDCOM', '');
287        }
288
289        DB::table('gedcom_chunk')->where('gedcom_id', '=', $tree->id())->delete();
290        DB::table('individuals')->where('i_file', '=', $tree->id())->delete();
291        DB::table('families')->where('f_file', '=', $tree->id())->delete();
292        DB::table('sources')->where('s_file', '=', $tree->id())->delete();
293        DB::table('other')->where('o_file', '=', $tree->id())->delete();
294        DB::table('places')->where('p_file', '=', $tree->id())->delete();
295        DB::table('placelinks')->where('pl_file', '=', $tree->id())->delete();
296        DB::table('name')->where('n_file', '=', $tree->id())->delete();
297        DB::table('dates')->where('d_file', '=', $tree->id())->delete();
298        DB::table('change')->where('gedcom_id', '=', $tree->id())->delete();
299        DB::table('link')->where('l_file', '=', $tree->id())->delete();
300        DB::table('media_file')->where('m_file', '=', $tree->id())->delete();
301        DB::table('media')->where('m_file', '=', $tree->id())->delete();
302        DB::table('block_setting')
303            ->join('block', 'block.block_id', '=', 'block_setting.block_id')
304            ->where('gedcom_id', '=', $tree->id())
305            ->delete();
306        DB::table('block')->where('gedcom_id', '=', $tree->id())->delete();
307        DB::table('user_gedcom_setting')->where('gedcom_id', '=', $tree->id())->delete();
308        DB::table('gedcom_setting')->where('gedcom_id', '=', $tree->id())->delete();
309        DB::table('module_privacy')->where('gedcom_id', '=', $tree->id())->delete();
310        DB::table('hit_counter')->where('gedcom_id', '=', $tree->id())->delete();
311        DB::table('default_resn')->where('gedcom_id', '=', $tree->id())->delete();
312        DB::table('gedcom_chunk')->where('gedcom_id', '=', $tree->id())->delete();
313        DB::table('log')->where('gedcom_id', '=', $tree->id())->delete();
314        DB::table('gedcom')->where('gedcom_id', '=', $tree->id())->delete();
315    }
316
317    /**
318     * Generate a unique name for a new tree.
319     *
320     * @return string
321     */
322    public function uniqueTreeName(): string
323    {
324        $name   = 'tree';
325        $number = 1;
326
327        while ($this->all()->get($name . $number) instanceof Tree) {
328            $number++;
329        }
330
331        return $name . $number;
332    }
333}
334