xref: /webtrees/app/Services/GedcomService.php (revision 273a564e3de4d5154e894fa56a60fe99383cf8b2)
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\Gedcom;
23
24/**
25 * Utilities for manipulating GEDCOM data.
26 */
27class GedcomService
28{
29    // User defined tags begin with an underscore
30    private const USER_DEFINED_TAG_PREFIX = '_';
31
32    // Some applications, such as FTM, use GEDCOM tag names instead of the tags.
33    private const TAG_NAMES = [
34        'ABBREVIATION'      => 'ABBR',
35        'ADDRESS'           => 'ADDR',
36        'ADDRESS1'          => 'ADR1',
37        'ADDRESS2'          => 'ADR2',
38        'ADOPTION'          => 'ADOP',
39        'AGENCY'            => 'AGNC',
40        'ALIAS'             => 'ALIA',
41        'ANCESTORS'         => 'ANCE',
42        'ANCES_INTEREST'    => 'ANCI',
43        'ANULMENT'          => 'ANUL',
44        'ASSOCIATES'        => 'ASSO',
45        'AUTHOR'            => 'AUTH',
46        'BAPTISM-LDS'       => 'BAPL',
47        'BAPTISM'           => 'BAPM',
48        'BAR_MITZVAH'       => 'BARM',
49        'BAS_MITZVAH'       => 'BASM',
50        'BIRTH'             => 'BIRT',
51        'BLESSING'          => 'BLES',
52        'BURIAL'            => 'BURI',
53        'CALL_NUMBER'       => 'CALN',
54        'CASTE'             => 'CAST',
55        'CAUSE'             => 'CAUS',
56        'CENSUS'            => 'CENS',
57        'CHANGE'            => 'CHAN',
58        'CHARACTER'         => 'CHAR',
59        'CHILD'             => 'CHIL',
60        'CHRISTENING'       => 'CHR',
61        'ADULT_CHRISTENING' => 'CHRA',
62        'CONCATENATION'     => 'CONC',
63        'CONFIRMATION'      => 'CONF',
64        'CONFIRMATION-LDS'  => 'CONL',
65        'CONTINUED'         => 'CONT',
66        'COPYRIGHT'         => 'COPY',
67        'CORPORTATE'        => 'CORP',
68        'CREMATION'         => 'CREM',
69        'COUNTRY'           => 'CTRY',
70        'DEATH'             => 'DEAT',
71        'DESCENDANTS'       => 'DESC',
72        'DESCENDANTS_INT'   => 'DESI',
73        'DESTINATION'       => 'DEST',
74        'DIVORCE'           => 'DIV',
75        'DIVORCE_FILED'     => 'DIVF',
76        'PHY_DESCRIPTION'   => 'DSCR',
77        'EDUCATION'         => 'EDUC',
78        'EMIGRATION'        => 'EMIG',
79        'ENDOWMENT'         => 'ENDL',
80        'ENGAGEMENT'        => 'ENGA',
81        'EVENT'             => 'EVEN',
82        'FAMILY'            => 'FAM',
83        'FAMILY_CHILD'      => 'FAMC',
84        'FAMILY_FILE'       => 'FAMF',
85        'FAMILY_SPOUSE'     => 'FAMS',
86        'FACIMILIE'         => 'FAX',
87        'FIRST_COMMUNION'   => 'FCOM',
88        'FORMAT'            => 'FORM',
89        'PHONETIC'          => 'FONE',
90        'GEDCOM'            => 'GEDC',
91        'GIVEN_NAME'        => 'GIVN',
92        'GRADUATION'        => 'GRAD',
93        'HEADER'            => 'HEAD',
94        'HUSBAND'           => 'HUSB',
95        'IDENT_NUMBER'      => 'IDNO',
96        'IMMIGRATION'       => 'IMMI',
97        'INDIVIDUAL'        => 'INDI',
98        'LANGUAGE'          => 'LANG',
99        'LATITUDE'          => 'LATI',
100        'LONGITUDE'         => 'LONG',
101        'MARRIAGE_BANN'     => 'MARB',
102        'MARR_CONTRACT'     => 'MARC',
103        'MARR_LICENSE'      => 'MARL',
104        'MARRIAGE'          => 'MARR',
105        'MEDIA'             => 'MEDI',
106        'NATIONALITY'       => 'NATI',
107        'NATURALIZATION'    => 'NATU',
108        'CHILDREN_COUNT'    => 'NCHI',
109        'NICKNAME'          => 'NICK',
110        'MARRIAGE_COUNT'    => 'NMR',
111        'NAME_PREFIX'       => 'NPFX',
112        'NAME_SUFFIX'       => 'NSFX',
113        'OBJECT'            => 'OBJE',
114        'OCCUPATION'        => 'OCCU',
115        'ORDINANCE'         => 'ORDI',
116        'ORDINATION'        => 'ORDN',
117        'PEDIGREE'          => 'PEDI',
118        'PHONE'             => 'PHON',
119        'PLACE'             => 'PLAC',
120        'POSTAL_CODE'       => 'POST',
121        'PROBATE'           => 'PROB',
122        'PROPERTY'          => 'PROP',
123        'PUBLICATION'       => 'PUBL',
124        'QUALITY_OF_DATA'   => 'QUAY',
125        'REFERENCE'         => 'REFN',
126        'RELATIONSHIP'      => 'RELA',
127        'RELIGION'          => 'RELI',
128        'REPOSITORY'        => 'REPO',
129        'RESIDENCE'         => 'RESI',
130        'RESTRICTION'       => 'RESN',
131        'RETIREMENT'        => 'RETI',
132        'REC_FILE_NUMBER'   => 'RFN',
133        'REC_ID_NUMBER'     => 'RIN',
134        'ROMANIZED'         => 'ROMN',
135        'SEALING_CHILD'     => 'SLGC',
136        'SEALING_SPOUSE'    => 'SLGS',
137        'SOURCE'            => 'SOUR',
138        'SURN_PREFIX'       => 'SPFX',
139        'SOC_SEC_NUMBER'    => 'SSN',
140        'STATE'             => 'STAE',
141        'STATUS'            => 'STAT',
142        'SUBMITTER'         => 'SUBM',
143        'SUBMISSION'        => 'SUBN',
144        'SURNAME'           => 'SURN',
145        'TEMPLE'            => 'TEMP',
146        'TITLE'             => 'TITL',
147        'TRAILER'           => 'TRLR',
148        'VERSION'           => 'VERS',
149        'WEB'               => 'WWW',
150        '_DEATH_OF_SPOUSE'  => 'DETS',
151        '_DEGREE'           => '_DEG',
152        '_MEDICAL'          => '_MCL',
153        '_MILITARY_SERVICE' => '_MILT',
154    ];
155
156    // Custom GEDCOM tags used by other applications, with direct synonyms
157    private const TAG_SYNONYMS = [
158        // Convert PhpGedView tag to webtrees
159        '_PGVU'     => '_WT_USER',
160        '_PGV_OBJS' => '_WT_OBJE_SORT',
161    ];
162
163    // SEX tags
164    private const SEX_FEMALE  = 'F';
165    private const SEX_MALE    = 'M';
166    private const SEX_UNKNOWN = 'U';
167
168    /**
169     * Convert a GEDCOM tag to a canonical form.
170     *
171     * @param string $tag
172     *
173     * @return string
174     */
175    public function canonicalTag(string $tag): string
176    {
177        $tag = strtoupper($tag);
178
179        $tag = self::TAG_NAMES[$tag] ?? self::TAG_SYNONYMS[$tag] ?? $tag;
180
181        return $tag;
182    }
183
184    /**
185     * @param string $tag
186     *
187     * @return bool
188     */
189    public function isUserDefinedTag(string $tag): bool
190    {
191        return substr_compare($tag, self::USER_DEFINED_TAG_PREFIX, 0, 1) === 0;
192    }
193
194    /**
195     * @param string $text
196     *
197     * @return float|null
198     */
199    public function readLatitude(string $text): ?float
200    {
201        return $this->readDegrees($text, Gedcom::LATITUDE_NORTH, Gedcom::LATITUDE_SOUTH);
202    }
203
204    /**
205     * @param string $text
206     *
207     * @return float|null
208     */
209    public function readLongitude(string $text): ?float
210    {
211        return $this->readDegrees($text, Gedcom::LONGITUDE_EAST, Gedcom::LONGITUDE_WEST);
212    }
213
214    /**
215     * @param string $text
216     * @param string $positive
217     * @param string $negative
218     *
219     * @return float|null
220     */
221    private function readDegrees(string $text, string $positive, string $negative): ?float
222    {
223        $text       = trim($text);
224        $hemisphere = substr($text, 0, 1);
225        $degrees    = substr($text, 1);
226
227        // Match a valid GEDCOM format
228        if (is_numeric($degrees)) {
229            $hemisphere = strtoupper($hemisphere);
230            $degrees    = (float) $degrees;
231
232            if ($hemisphere === $positive) {
233                return $degrees;
234            }
235
236            if ($hemisphere === $negative) {
237                return -$degrees;
238            }
239        }
240
241        // Just a number?
242        if (is_numeric($text)) {
243            return (float) $text;
244        }
245
246        // Can't match anything.
247        return null;
248    }
249
250    /**
251     * Although empty placenames are valid "Town, , Country", it is only meaningful
252     * when structured places are used (PLAC:FORM town, county, country), and
253     * structured places are discouraged.
254     *
255     * @param string $text
256     *
257     * @return array<string>
258     */
259    public function readPlace(string $text): array
260    {
261        $text = trim($text);
262
263        return preg_split(Gedcom::PLACE_SEPARATOR_REGEX, $text);
264    }
265
266    /**
267     * @param string[] $place
268     *
269     * @return string
270     */
271    public function writePlace(array $place): string
272    {
273        return implode(Gedcom::PLACE_SEPARATOR, $place);
274    }
275
276    /**
277     * Some applications use non-standard values for unknown.
278     *
279     * @param string $text
280     *
281     * @return string
282     */
283    public function readSex(string $text): string
284    {
285        $text = strtoupper($text);
286
287        if ($text !== self::SEX_MALE && $text !== self::SEX_FEMALE) {
288            $text = self::SEX_UNKNOWN;
289        }
290
291        return $text;
292    }
293}
294