xref: /webtrees/app/Services/GedcomExportService.php (revision 6830c5c1f08a8c0f37147c0884175ea7fd030dfe)
169c05a6eSGreg Roach<?php
269c05a6eSGreg Roach
369c05a6eSGreg Roach/**
469c05a6eSGreg Roach * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
669c05a6eSGreg Roach * This program is free software: you can redistribute it and/or modify
769c05a6eSGreg Roach * it under the terms of the GNU General Public License as published by
869c05a6eSGreg Roach * the Free Software Foundation, either version 3 of the License, or
969c05a6eSGreg Roach * (at your option) any later version.
1069c05a6eSGreg Roach * This program is distributed in the hope that it will be useful,
1169c05a6eSGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
1269c05a6eSGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1369c05a6eSGreg Roach * GNU General Public License for more details.
1469c05a6eSGreg Roach * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
1669c05a6eSGreg Roach */
1769c05a6eSGreg Roach
1869c05a6eSGreg Roachdeclare(strict_types=1);
1969c05a6eSGreg Roach
2069c05a6eSGreg Roachnamespace Fisharebest\Webtrees\Services;
2169c05a6eSGreg Roach
2269c05a6eSGreg Roachuse Fisharebest\Webtrees\Auth;
236f4ec3caSGreg Roachuse Fisharebest\Webtrees\DB;
241c6adce8SGreg Roachuse Fisharebest\Webtrees\Encodings\UTF16BE;
251c6adce8SGreg Roachuse Fisharebest\Webtrees\Encodings\UTF16LE;
261c6adce8SGreg Roachuse Fisharebest\Webtrees\Encodings\UTF8;
271c6adce8SGreg Roachuse Fisharebest\Webtrees\Encodings\Windows1252;
2869c05a6eSGreg Roachuse Fisharebest\Webtrees\Factories\AbstractGedcomRecordFactory;
2969c05a6eSGreg Roachuse Fisharebest\Webtrees\Gedcom;
301c6adce8SGreg Roachuse Fisharebest\Webtrees\GedcomFilters\GedcomEncodingFilter;
3169c05a6eSGreg Roachuse Fisharebest\Webtrees\GedcomRecord;
3269c05a6eSGreg Roachuse Fisharebest\Webtrees\Header;
331c6adce8SGreg Roachuse Fisharebest\Webtrees\Registry;
3469c05a6eSGreg Roachuse Fisharebest\Webtrees\Tree;
3569c05a6eSGreg Roachuse Fisharebest\Webtrees\Webtrees;
3669c05a6eSGreg Roachuse Illuminate\Database\Query\Builder;
3769c05a6eSGreg Roachuse Illuminate\Database\Query\Expression;
3869c05a6eSGreg Roachuse Illuminate\Support\Collection;
3916ecfcafSGreg Roachuse League\Flysystem\Filesystem;
4016ecfcafSGreg Roachuse League\Flysystem\FilesystemOperator;
4116ecfcafSGreg Roachuse League\Flysystem\ZipArchive\FilesystemZipArchiveProvider;
4216ecfcafSGreg Roachuse League\Flysystem\ZipArchive\ZipArchiveAdapter;
4316ecfcafSGreg Roachuse Psr\Http\Message\ResponseFactoryInterface;
4416ecfcafSGreg Roachuse Psr\Http\Message\ResponseInterface;
4516ecfcafSGreg Roachuse Psr\Http\Message\StreamFactoryInterface;
46783b32e3SGreg Roachuse RuntimeException;
4769c05a6eSGreg Roach
4816ecfcafSGreg Roachuse function addcslashes;
4969c05a6eSGreg Roachuse function date;
5069c05a6eSGreg Roachuse function explode;
5116ecfcafSGreg Roachuse function fclose;
52ea517a3bSGreg Roachuse function fopen;
5369c05a6eSGreg Roachuse function fwrite;
5410e06497SGreg Roachuse function is_string;
5569c05a6eSGreg Roachuse function pathinfo;
5616ecfcafSGreg Roachuse function preg_match_all;
57ea517a3bSGreg Roachuse function rewind;
581c6adce8SGreg Roachuse function stream_filter_append;
5916ecfcafSGreg Roachuse function stream_get_meta_data;
60783b32e3SGreg Roachuse function strlen;
6169c05a6eSGreg Roachuse function strpos;
6269c05a6eSGreg Roachuse function strtolower;
6369c05a6eSGreg Roachuse function strtoupper;
6416ecfcafSGreg Roachuse function tmpfile;
6569c05a6eSGreg Roach
6669c05a6eSGreg Roachuse const PATHINFO_EXTENSION;
6716ecfcafSGreg Roachuse const PREG_SET_ORDER;
681c6adce8SGreg Roachuse const STREAM_FILTER_WRITE;
6969c05a6eSGreg Roach
7069c05a6eSGreg Roach/**
7169c05a6eSGreg Roach * Export data in GEDCOM format
7269c05a6eSGreg Roach */
7369c05a6eSGreg Roachclass GedcomExportService
7469c05a6eSGreg Roach{
7516ecfcafSGreg Roach    private const ACCESS_LEVELS = [
7616ecfcafSGreg Roach        'gedadmin' => Auth::PRIV_NONE,
7716ecfcafSGreg Roach        'user'     => Auth::PRIV_USER,
7816ecfcafSGreg Roach        'visitor'  => Auth::PRIV_PRIVATE,
7916ecfcafSGreg Roach        'none'     => Auth::PRIV_HIDE,
8016ecfcafSGreg Roach    ];
8116ecfcafSGreg Roach
8216ecfcafSGreg Roach    private ResponseFactoryInterface $response_factory;
8316ecfcafSGreg Roach
8416ecfcafSGreg Roach    private StreamFactoryInterface $stream_factory;
8516ecfcafSGreg Roach
8616ecfcafSGreg Roach    public function __construct(ResponseFactoryInterface $response_factory, StreamFactoryInterface $stream_factory)
8716ecfcafSGreg Roach    {
8816ecfcafSGreg Roach        $this->response_factory = $response_factory;
8916ecfcafSGreg Roach        $this->stream_factory   = $stream_factory;
9016ecfcafSGreg Roach    }
9116ecfcafSGreg Roach
9216ecfcafSGreg Roach    /**
93e5766395SGreg Roach     * @param Tree                                            $tree         Export data from this tree
94e5766395SGreg Roach     * @param bool                                            $sort_by_xref Write GEDCOM records in XREF order
95e5766395SGreg Roach     * @param string                                          $encoding     Convert from UTF-8 to other encoding
96e5766395SGreg Roach     * @param string                                          $privacy      Filter records by role
97*6830c5c1SGreg Roach     * @param string                                          $line_endings CRLF or LF
98e5766395SGreg Roach     * @param string                                          $filename     Name of download file, without an extension
99e5766395SGreg Roach     * @param string                                          $format       One of: gedcom, zip, zipmedia, gedzip
100e5766395SGreg Roach     * @param Collection<int,string|object|GedcomRecord>|null $records
10116ecfcafSGreg Roach     */
10216ecfcafSGreg Roach    public function downloadResponse(
10316ecfcafSGreg Roach        Tree $tree,
10416ecfcafSGreg Roach        bool $sort_by_xref,
10516ecfcafSGreg Roach        string $encoding,
10616ecfcafSGreg Roach        string $privacy,
10716ecfcafSGreg Roach        string $line_endings,
10816ecfcafSGreg Roach        string $filename,
10916ecfcafSGreg Roach        string $format,
11016ecfcafSGreg Roach        Collection $records = null
11116ecfcafSGreg Roach    ): ResponseInterface {
11216ecfcafSGreg Roach        $access_level = self::ACCESS_LEVELS[$privacy];
11316ecfcafSGreg Roach
11416ecfcafSGreg Roach        if ($format === 'gedcom') {
11516ecfcafSGreg Roach            $resource = $this->export($tree, $sort_by_xref, $encoding, $access_level, $line_endings, $records);
11616ecfcafSGreg Roach            $stream   = $this->stream_factory->createStreamFromResource($resource);
11716ecfcafSGreg Roach
11816ecfcafSGreg Roach            return $this->response_factory->createResponse()
11916ecfcafSGreg Roach                ->withBody($stream)
12016ecfcafSGreg Roach                ->withHeader('content-type', 'text/x-gedcom; charset=' . UTF8::NAME)
12116ecfcafSGreg Roach                ->withHeader('content-disposition', 'attachment; filename="' . addcslashes($filename, '"') . '.ged"');
12216ecfcafSGreg Roach        }
12316ecfcafSGreg Roach
12416ecfcafSGreg Roach        // Create a new/empty .ZIP file
12516ecfcafSGreg Roach        $temp_zip_file  = stream_get_meta_data(tmpfile())['uri'];
12616ecfcafSGreg Roach        $zip_provider   = new FilesystemZipArchiveProvider($temp_zip_file, 0755);
12716ecfcafSGreg Roach        $zip_adapter    = new ZipArchiveAdapter($zip_provider);
12816ecfcafSGreg Roach        $zip_filesystem = new Filesystem($zip_adapter);
12916ecfcafSGreg Roach
13016ecfcafSGreg Roach        if ($format === 'zipmedia') {
13116ecfcafSGreg Roach            $media_path = $tree->getPreference('MEDIA_DIRECTORY');
13216ecfcafSGreg Roach        } elseif ($format === 'gedzip') {
13316ecfcafSGreg Roach            $media_path = '';
13416ecfcafSGreg Roach        } else {
13516ecfcafSGreg Roach            // Don't add media
13616ecfcafSGreg Roach            $media_path = null;
13716ecfcafSGreg Roach        }
13816ecfcafSGreg Roach
13916ecfcafSGreg Roach        $resource = $this->export($tree, $sort_by_xref, $encoding, $access_level, $line_endings, $records, $zip_filesystem, $media_path);
14016ecfcafSGreg Roach
14116ecfcafSGreg Roach        if ($format === 'gedzip') {
14216ecfcafSGreg Roach            $zip_filesystem->writeStream('gedcom.ged', $resource);
14316ecfcafSGreg Roach            $extension = '.gdz';
14416ecfcafSGreg Roach        } else {
14516ecfcafSGreg Roach            $zip_filesystem->writeStream($filename . '.ged', $resource);
14616ecfcafSGreg Roach            $extension = '.zip';
14716ecfcafSGreg Roach        }
14816ecfcafSGreg Roach
14916ecfcafSGreg Roach        fclose($resource);
15016ecfcafSGreg Roach
15116ecfcafSGreg Roach        $stream = $this->stream_factory->createStreamFromFile($temp_zip_file);
15216ecfcafSGreg Roach
15316ecfcafSGreg Roach        return $this->response_factory->createResponse()
15416ecfcafSGreg Roach            ->withBody($stream)
15516ecfcafSGreg Roach            ->withHeader('content-type', 'application/zip')
15616ecfcafSGreg Roach            ->withHeader('content-disposition', 'attachment; filename="' . addcslashes($filename, '"') . $extension . '"');
15716ecfcafSGreg Roach    }
15816ecfcafSGreg Roach
15969c05a6eSGreg Roach    /**
16069c05a6eSGreg Roach     * Write GEDCOM data to a stream.
16169c05a6eSGreg Roach     *
162e5766395SGreg Roach     * @param Tree                                            $tree           Export data from this tree
163e5766395SGreg Roach     * @param bool                                            $sort_by_xref   Write GEDCOM records in XREF order
164e5766395SGreg Roach     * @param string                                          $encoding       Convert from UTF-8 to other encoding
165e5766395SGreg Roach     * @param int                                             $access_level   Apply privacy filtering
166e5766395SGreg Roach     * @param string                                          $line_endings   CRLF or LF
167e5766395SGreg Roach     * @param Collection<int,string|object|GedcomRecord>|null $records        Just export these records
168e5766395SGreg Roach     * @param FilesystemOperator|null                         $zip_filesystem Write media files to this filesystem
169e5766395SGreg Roach     * @param string|null                                     $media_path     Location within the zip filesystem
170ea517a3bSGreg Roach     *
171ea517a3bSGreg Roach     * @return resource
17269c05a6eSGreg Roach     */
17369c05a6eSGreg Roach    public function export(
17469c05a6eSGreg Roach        Tree $tree,
17569c05a6eSGreg Roach        bool $sort_by_xref = false,
1761c6adce8SGreg Roach        string $encoding = UTF8::NAME,
17769c05a6eSGreg Roach        int $access_level = Auth::PRIV_HIDE,
1781c6adce8SGreg Roach        string $line_endings = 'CRLF',
1792c6f1bd5SGreg Roach        Collection|null $records = null,
1802c6f1bd5SGreg Roach        FilesystemOperator|null $zip_filesystem = null,
18116ecfcafSGreg Roach        string $media_path = null
182ea517a3bSGreg Roach    ) {
183ea517a3bSGreg Roach        $stream = fopen('php://memory', 'wb+');
184ea517a3bSGreg Roach
185ea517a3bSGreg Roach        if ($stream === false) {
186ea517a3bSGreg Roach            throw new RuntimeException('Failed to create temporary stream');
187ea517a3bSGreg Roach        }
188ea517a3bSGreg Roach
1891c6adce8SGreg Roach        stream_filter_append($stream, GedcomEncodingFilter::class, STREAM_FILTER_WRITE, ['src_encoding' => UTF8::NAME, 'dst_encoding' => $encoding]);
1901c6adce8SGreg Roach
19169c05a6eSGreg Roach        if ($records instanceof Collection) {
19269c05a6eSGreg Roach            // Export just these records - e.g. from clippings cart.
19369c05a6eSGreg Roach            $data = [
19469c05a6eSGreg Roach                new Collection([$this->createHeader($tree, $encoding, false)]),
19569c05a6eSGreg Roach                $records,
19669c05a6eSGreg Roach                new Collection(['0 TRLR']),
19769c05a6eSGreg Roach            ];
19869c05a6eSGreg Roach        } elseif ($access_level === Auth::PRIV_HIDE) {
19969c05a6eSGreg Roach            // If we will be applying privacy filters, then we will need the GEDCOM record objects.
20069c05a6eSGreg Roach            $data = [
20169c05a6eSGreg Roach                new Collection([$this->createHeader($tree, $encoding, true)]),
20269c05a6eSGreg Roach                $this->individualQuery($tree, $sort_by_xref)->cursor(),
20369c05a6eSGreg Roach                $this->familyQuery($tree, $sort_by_xref)->cursor(),
20469c05a6eSGreg Roach                $this->sourceQuery($tree, $sort_by_xref)->cursor(),
20569c05a6eSGreg Roach                $this->otherQuery($tree, $sort_by_xref)->cursor(),
20669c05a6eSGreg Roach                $this->mediaQuery($tree, $sort_by_xref)->cursor(),
20769c05a6eSGreg Roach                new Collection(['0 TRLR']),
20869c05a6eSGreg Roach            ];
20969c05a6eSGreg Roach        } else {
21069c05a6eSGreg Roach            // Disable the pending changes before creating GEDCOM records.
211f25fc0f9SGreg Roach            Registry::cache()->array()->remember(AbstractGedcomRecordFactory::class . $tree->id(), static fn (): Collection => new Collection());
21269c05a6eSGreg Roach
21369c05a6eSGreg Roach            $data = [
21469c05a6eSGreg Roach                new Collection([$this->createHeader($tree, $encoding, true)]),
2156b9cb339SGreg Roach                $this->individualQuery($tree, $sort_by_xref)->get()->map(Registry::individualFactory()->mapper($tree)),
2166b9cb339SGreg Roach                $this->familyQuery($tree, $sort_by_xref)->get()->map(Registry::familyFactory()->mapper($tree)),
2176b9cb339SGreg Roach                $this->sourceQuery($tree, $sort_by_xref)->get()->map(Registry::sourceFactory()->mapper($tree)),
2186b9cb339SGreg Roach                $this->otherQuery($tree, $sort_by_xref)->get()->map(Registry::gedcomRecordFactory()->mapper($tree)),
2196b9cb339SGreg Roach                $this->mediaQuery($tree, $sort_by_xref)->get()->map(Registry::mediaFactory()->mapper($tree)),
22069c05a6eSGreg Roach                new Collection(['0 TRLR']),
22169c05a6eSGreg Roach            ];
22269c05a6eSGreg Roach        }
22369c05a6eSGreg Roach
2249458f20aSGreg Roach        $media_filesystem = $tree->mediaFilesystem();
22516ecfcafSGreg Roach
22669c05a6eSGreg Roach        foreach ($data as $rows) {
22769c05a6eSGreg Roach            foreach ($rows as $datum) {
22869c05a6eSGreg Roach                if (is_string($datum)) {
22969c05a6eSGreg Roach                    $gedcom = $datum;
23069c05a6eSGreg Roach                } elseif ($datum instanceof GedcomRecord) {
23169c05a6eSGreg Roach                    $gedcom = $datum->privatizeGedcom($access_level);
232*6830c5c1SGreg Roach
233*6830c5c1SGreg Roach                    if ($gedcom === '') {
234*6830c5c1SGreg Roach                        continue;
235*6830c5c1SGreg Roach                    }
23669c05a6eSGreg Roach                } else {
237813bb733SGreg Roach                    $gedcom =
238813bb733SGreg Roach                        $datum->i_gedcom ??
239813bb733SGreg Roach                        $datum->f_gedcom ??
240813bb733SGreg Roach                        $datum->s_gedcom ??
241813bb733SGreg Roach                        $datum->m_gedcom ??
242813bb733SGreg Roach                        $datum->o_gedcom;
24369c05a6eSGreg Roach                }
24469c05a6eSGreg Roach
24516ecfcafSGreg Roach                if ($media_path !== null && $zip_filesystem !== null && preg_match('/0 @' . Gedcom::REGEX_XREF . '@ OBJE/', $gedcom) === 1) {
24616ecfcafSGreg Roach                    preg_match_all('/\n1 FILE (.+)/', $gedcom, $matches, PREG_SET_ORDER);
24716ecfcafSGreg Roach
24816ecfcafSGreg Roach                    foreach ($matches as $match) {
24916ecfcafSGreg Roach                        $media_file = $match[1];
25016ecfcafSGreg Roach
25116ecfcafSGreg Roach                        if ($media_filesystem->fileExists($media_file)) {
25216ecfcafSGreg Roach                            $zip_filesystem->writeStream($media_path . $media_file, $media_filesystem->readStream($media_file));
25316ecfcafSGreg Roach                        }
25416ecfcafSGreg Roach                    }
25569c05a6eSGreg Roach                }
25669c05a6eSGreg Roach
2571c6adce8SGreg Roach                $gedcom = $this->wrapLongLines($gedcom, Gedcom::LINE_LENGTH) . "\n";
2581c6adce8SGreg Roach
2591c6adce8SGreg Roach                if ($line_endings === 'CRLF') {
2601c6adce8SGreg Roach                    $gedcom = strtr($gedcom, ["\n" => "\r\n"]);
2611c6adce8SGreg Roach                }
26269c05a6eSGreg Roach
263783b32e3SGreg Roach                $bytes_written = fwrite($stream, $gedcom);
264783b32e3SGreg Roach
265783b32e3SGreg Roach                if ($bytes_written !== strlen($gedcom)) {
266783b32e3SGreg Roach                    throw new RuntimeException('Unable to write to stream.  Perhaps the disk is full?');
267783b32e3SGreg Roach                }
26869c05a6eSGreg Roach            }
26969c05a6eSGreg Roach        }
270ea517a3bSGreg Roach
271ea517a3bSGreg Roach        if (rewind($stream) === false) {
272ea517a3bSGreg Roach            throw new RuntimeException('Cannot rewind temporary stream');
273ea517a3bSGreg Roach        }
274ea517a3bSGreg Roach
275ea517a3bSGreg Roach        return $stream;
27669c05a6eSGreg Roach    }
27769c05a6eSGreg Roach
27869c05a6eSGreg Roach    public function createHeader(Tree $tree, string $encoding, bool $include_sub): string
27969c05a6eSGreg Roach    {
28069c05a6eSGreg Roach        // Force a ".ged" suffix
28169c05a6eSGreg Roach        $filename = $tree->name();
28269c05a6eSGreg Roach
28369c05a6eSGreg Roach        if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'ged') {
28469c05a6eSGreg Roach            $filename .= '.ged';
28569c05a6eSGreg Roach        }
28669c05a6eSGreg Roach
2871c6adce8SGreg Roach        $gedcom_encodings = [
2881c6adce8SGreg Roach            UTF16BE::NAME     => 'UNICODE',
2891c6adce8SGreg Roach            UTF16LE::NAME     => 'UNICODE',
2901c6adce8SGreg Roach            Windows1252::NAME => 'ANSI',
2911c6adce8SGreg Roach        ];
2921c6adce8SGreg Roach
2931c6adce8SGreg Roach        $encoding = $gedcom_encodings[$encoding] ?? $encoding;
2941c6adce8SGreg Roach
29569c05a6eSGreg Roach        // Build a new header record
29669c05a6eSGreg Roach        $gedcom = '0 HEAD';
29769c05a6eSGreg Roach        $gedcom .= "\n1 SOUR " . Webtrees::NAME;
29869c05a6eSGreg Roach        $gedcom .= "\n2 NAME " . Webtrees::NAME;
29969c05a6eSGreg Roach        $gedcom .= "\n2 VERS " . Webtrees::VERSION;
30069c05a6eSGreg Roach        $gedcom .= "\n1 DEST DISKETTE";
30169c05a6eSGreg Roach        $gedcom .= "\n1 DATE " . strtoupper(date('d M Y'));
30269c05a6eSGreg Roach        $gedcom .= "\n2 TIME " . date('H:i:s');
30388a91440SGreg Roach        $gedcom .= "\n1 GEDC\n2 VERS 5.5.1\n2 FORM LINEAGE-LINKED";
30469c05a6eSGreg Roach        $gedcom .= "\n1 CHAR " . $encoding;
30569c05a6eSGreg Roach        $gedcom .= "\n1 FILE " . $filename;
30669c05a6eSGreg Roach
30769c05a6eSGreg Roach        // Preserve some values from the original header
3086b9cb339SGreg Roach        $header = Registry::headerFactory()->make('HEAD', $tree) ?? Registry::headerFactory()->new('HEAD', '0 HEAD', null, $tree);
30969c05a6eSGreg Roach
310771780e6SGreg Roach        // There should always be a header record.
311771780e6SGreg Roach        if ($header instanceof Header) {
31269c05a6eSGreg Roach            foreach ($header->facts(['COPR', 'LANG', 'PLAC', 'NOTE']) as $fact) {
31369c05a6eSGreg Roach                $gedcom .= "\n" . $fact->gedcom();
31469c05a6eSGreg Roach            }
31569c05a6eSGreg Roach
31669c05a6eSGreg Roach            if ($include_sub) {
31769c05a6eSGreg Roach                foreach ($header->facts(['SUBM', 'SUBN']) as $fact) {
31869c05a6eSGreg Roach                    $gedcom .= "\n" . $fact->gedcom();
31969c05a6eSGreg Roach                }
32069c05a6eSGreg Roach            }
321771780e6SGreg Roach        }
32269c05a6eSGreg Roach
32369c05a6eSGreg Roach        return $gedcom;
32469c05a6eSGreg Roach    }
32569c05a6eSGreg Roach
32669c05a6eSGreg Roach    public function wrapLongLines(string $gedcom, int $max_line_length): string
32769c05a6eSGreg Roach    {
32869c05a6eSGreg Roach        $lines = [];
32969c05a6eSGreg Roach
33069c05a6eSGreg Roach        foreach (explode("\n", $gedcom) as $line) {
33169c05a6eSGreg Roach            // Split long lines
33269c05a6eSGreg Roach            // The total length of a GEDCOM line, including level number, cross-reference number,
33369c05a6eSGreg Roach            // tag, value, delimiters, and terminator, must not exceed 255 (wide) characters.
33469c05a6eSGreg Roach            if (mb_strlen($line) > $max_line_length) {
33569c05a6eSGreg Roach                [$level, $tag] = explode(' ', $line, 3);
33669c05a6eSGreg Roach                if ($tag !== 'CONT') {
33769c05a6eSGreg Roach                    $level++;
33869c05a6eSGreg Roach                }
33969c05a6eSGreg Roach                do {
34069c05a6eSGreg Roach                    // Split after $pos chars
34169c05a6eSGreg Roach                    $pos = $max_line_length;
34269c05a6eSGreg Roach                    // Split on a non-space (standard gedcom behavior)
34369c05a6eSGreg Roach                    while (mb_substr($line, $pos - 1, 1) === ' ') {
34469c05a6eSGreg Roach                        --$pos;
34569c05a6eSGreg Roach                    }
34669c05a6eSGreg Roach                    if ($pos === strpos($line, ' ', 3)) {
34769c05a6eSGreg Roach                        // No non-spaces in the data! Can’t split it :-(
34869c05a6eSGreg Roach                        break;
34969c05a6eSGreg Roach                    }
35069c05a6eSGreg Roach                    $lines[] = mb_substr($line, 0, $pos);
35169c05a6eSGreg Roach                    $line    = $level . ' CONC ' . mb_substr($line, $pos);
35269c05a6eSGreg Roach                } while (mb_strlen($line) > $max_line_length);
35369c05a6eSGreg Roach            }
35469c05a6eSGreg Roach            $lines[] = $line;
35569c05a6eSGreg Roach        }
35669c05a6eSGreg Roach
3571c6adce8SGreg Roach        return implode("\n", $lines);
35869c05a6eSGreg Roach    }
35969c05a6eSGreg Roach
36069c05a6eSGreg Roach    private function familyQuery(Tree $tree, bool $sort_by_xref): Builder
36169c05a6eSGreg Roach    {
36269c05a6eSGreg Roach        $query = DB::table('families')
36369c05a6eSGreg Roach            ->where('f_file', '=', $tree->id())
364813bb733SGreg Roach            ->select(['f_gedcom', 'f_id']);
36569c05a6eSGreg Roach
36669c05a6eSGreg Roach        if ($sort_by_xref) {
36769c05a6eSGreg Roach            $query
36869c05a6eSGreg Roach                ->orderBy(new Expression('LENGTH(f_id)'))
36969c05a6eSGreg Roach                ->orderBy('f_id');
37069c05a6eSGreg Roach        }
37169c05a6eSGreg Roach
37269c05a6eSGreg Roach        return $query;
37369c05a6eSGreg Roach    }
37469c05a6eSGreg Roach
37569c05a6eSGreg Roach    private function individualQuery(Tree $tree, bool $sort_by_xref): Builder
37669c05a6eSGreg Roach    {
37769c05a6eSGreg Roach        $query = DB::table('individuals')
37869c05a6eSGreg Roach            ->where('i_file', '=', $tree->id())
379813bb733SGreg Roach            ->select(['i_gedcom', 'i_id']);
38069c05a6eSGreg Roach
38169c05a6eSGreg Roach        if ($sort_by_xref) {
38269c05a6eSGreg Roach            $query
38369c05a6eSGreg Roach                ->orderBy(new Expression('LENGTH(i_id)'))
38469c05a6eSGreg Roach                ->orderBy('i_id');
38569c05a6eSGreg Roach        }
38669c05a6eSGreg Roach
38769c05a6eSGreg Roach        return $query;
38869c05a6eSGreg Roach    }
38969c05a6eSGreg Roach
39069c05a6eSGreg Roach    private function sourceQuery(Tree $tree, bool $sort_by_xref): Builder
39169c05a6eSGreg Roach    {
39269c05a6eSGreg Roach        $query = DB::table('sources')
39369c05a6eSGreg Roach            ->where('s_file', '=', $tree->id())
394813bb733SGreg Roach            ->select(['s_gedcom', 's_id']);
39569c05a6eSGreg Roach
39669c05a6eSGreg Roach        if ($sort_by_xref) {
39769c05a6eSGreg Roach            $query
39869c05a6eSGreg Roach                ->orderBy(new Expression('LENGTH(s_id)'))
39969c05a6eSGreg Roach                ->orderBy('s_id');
40069c05a6eSGreg Roach        }
40169c05a6eSGreg Roach
40269c05a6eSGreg Roach        return $query;
40369c05a6eSGreg Roach    }
40469c05a6eSGreg Roach
40569c05a6eSGreg Roach    private function mediaQuery(Tree $tree, bool $sort_by_xref): Builder
40669c05a6eSGreg Roach    {
40769c05a6eSGreg Roach        $query = DB::table('media')
40869c05a6eSGreg Roach            ->where('m_file', '=', $tree->id())
409813bb733SGreg Roach            ->select(['m_gedcom', 'm_id']);
41069c05a6eSGreg Roach
41169c05a6eSGreg Roach        if ($sort_by_xref) {
41269c05a6eSGreg Roach            $query
41369c05a6eSGreg Roach                ->orderBy(new Expression('LENGTH(m_id)'))
41469c05a6eSGreg Roach                ->orderBy('m_id');
41569c05a6eSGreg Roach        }
41669c05a6eSGreg Roach
41769c05a6eSGreg Roach        return $query;
41869c05a6eSGreg Roach    }
41969c05a6eSGreg Roach
42069c05a6eSGreg Roach    private function otherQuery(Tree $tree, bool $sort_by_xref): Builder
42169c05a6eSGreg Roach    {
42269c05a6eSGreg Roach        $query = DB::table('other')
42369c05a6eSGreg Roach            ->where('o_file', '=', $tree->id())
42469c05a6eSGreg Roach            ->whereNotIn('o_type', [Header::RECORD_TYPE, 'TRLR'])
425813bb733SGreg Roach            ->select(['o_gedcom', 'o_id']);
42669c05a6eSGreg Roach
42769c05a6eSGreg Roach        if ($sort_by_xref) {
42869c05a6eSGreg Roach            $query
42969c05a6eSGreg Roach                ->orderBy('o_type')
43069c05a6eSGreg Roach                ->orderBy(new Expression('LENGTH(o_id)'))
43169c05a6eSGreg Roach                ->orderBy('o_id');
43269c05a6eSGreg Roach        }
43369c05a6eSGreg Roach
43469c05a6eSGreg Roach        return $query;
43569c05a6eSGreg Roach    }
43669c05a6eSGreg Roach}
437