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