Passed
Push — master ( f5be59...b5f5af )
by Greg
06:01
created

LocationController::childLocationStatus()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 29
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 25
nc 1
nop 1
dl 0
loc 29
rs 9.52
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\Controllers\Admin;
21
22
use Exception;
23
use Fisharebest\Webtrees\FlashMessages;
24
use Fisharebest\Webtrees\Gedcom;
25
use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
26
use Fisharebest\Webtrees\I18N;
27
use Fisharebest\Webtrees\Location;
28
use Fisharebest\Webtrees\Services\GedcomService;
29
use Fisharebest\Webtrees\Services\TreeService;
30
use Fisharebest\Webtrees\Tree;
31
use Illuminate\Database\Capsule\Manager as DB;
32
use Illuminate\Database\Eloquent\Collection;
33
use Illuminate\Database\Query\Expression;
34
use Illuminate\Database\Query\JoinClause;
35
use Illuminate\Database\QueryException;
36
use League\Flysystem\FilesystemInterface;
37
use Psr\Http\Message\ResponseInterface;
38
use Psr\Http\Message\ServerRequestInterface;
39
use Psr\Http\Message\UploadedFileInterface;
40
use stdClass;
41
42
use function abs;
43
use function addcslashes;
44
use function array_combine;
45
use function array_filter;
46
use function array_merge;
47
use function array_pad;
48
use function array_pop;
49
use function array_reverse;
50
use function array_search;
51
use function array_shift;
52
use function array_slice;
53
use function assert;
54
use function count;
55
use function e;
56
use function explode;
57
use function fclose;
58
use function fgetcsv;
59
use function fopen;
60
use function fputcsv;
61
use function implode;
62
use function is_numeric;
63
use function is_string;
64
use function json_decode;
65
use function preg_replace;
66
use function redirect;
67
use function response;
68
use function rewind;
69
use function round;
70
use function route;
71
use function str_replace;
72
use function stream_get_contents;
73
use function stripos;
74
use function substr_count;
75
76
use const UPLOAD_ERR_OK;
77
78
/**
79
 * Controller for maintaining geographic data.
80
 */
81
class LocationController extends AbstractAdminController
82
{
83
    // Location of files to import
84
    private const PLACES_FOLDER = 'places/';
85
86
    /** @var GedcomService */
87
    private $gedcom_service;
88
89
    /** @var TreeService */
90
    private $tree_service;
91
92
    /**
93
     * Dependency injection.
94
     *
95
     * @param GedcomService $gedcom_service
96
     * @param TreeService   $tree_service
97
     */
98
    public function __construct(GedcomService $gedcom_service, TreeService $tree_service)
99
    {
100
        $this->gedcom_service = $gedcom_service;
101
        $this->tree_service   = $tree_service;
102
    }
103
104
    /**
105
     * @param ServerRequestInterface $request
106
     *
107
     * @return ResponseInterface
108
     */
109
    public function mapData(ServerRequestInterface $request): ResponseInterface
110
    {
111
        $parent_id   = (int) ($request->getQueryParams()['parent_id'] ?? 0);
112
        $hierarchy   = $this->getHierarchy($parent_id);
113
        $title       = I18N::translate('Geographic data');
114
        $breadcrumbs = [
115
            route(ControlPanel::class) => I18N::translate('Control panel'),
116
            route('map-data')            => $title,
117
        ];
118
119
        foreach ($hierarchy as $row) {
120
            $breadcrumbs[route('map-data', ['parent_id' => $row->pl_id])] = $row->pl_place;
121
        }
122
        $breadcrumbs[] = array_pop($breadcrumbs);
123
124
        return $this->viewResponse('admin/locations', [
125
            'title'       => $title,
126
            'breadcrumbs' => $breadcrumbs,
127
            'parent_id'   => $parent_id,
128
            'placelist'   => $this->getPlaceListLocation($parent_id),
129
            'tree_titles' => $this->tree_service->titles(),
130
        ]);
131
    }
132
133
    /**
134
     * @param int $id
135
     *
136
     * @return array
137
     */
138
    private function getHierarchy(int $id): array
139
    {
140
        $arr  = [];
141
        $fqpn = [];
142
143
        while ($id !== 0) {
144
            $row = DB::table('placelocation')
145
                ->where('pl_id', '=', $id)
146
                ->first();
147
148
            $fqpn[]    = $row->pl_place;
149
            $row->fqpn = implode(Gedcom::PLACE_SEPARATOR, $fqpn);
150
            $id        = (int) $row->pl_parent_id;
151
            $arr[]     = $row;
152
        }
153
154
        return array_reverse($arr);
155
    }
156
157
    /**
158
     * Find all of the places in the hierarchy
159
     *
160
     * @param int $id
161
     *
162
     * @return stdClass[]
163
     */
164
    private function getPlaceListLocation(int $id): array
165
    {
166
        // We know the id of the place in the placelocation table,
167
        // now get the id of the same place in the places table
168
        if ($id === 0) {
169
            $fqpn = '';
170
        } else {
171
            $hierarchy = $this->getHierarchy($id);
172
            $fqpn      = ', ' . $hierarchy[0]->fqpn;
173
        }
174
175
        $rows = DB::table('placelocation')
176
            ->where('pl_parent_id', '=', $id)
177
            ->orderBy('pl_place')
178
            ->get();
179
180
        $list = [];
181
        foreach ($rows as $row) {
182
            // Find/count places without co-ordinates
183
            $children = $this->childLocationStatus((int) $row->pl_id);
184
            $active   = $this->isLocationActive($row->pl_place . $fqpn);
185
186
            if (!$active) {
187
                $badge = 'danger';
188
            } elseif ((int) $children->no_coord > 0) {
189
                $badge = 'warning';
190
            } elseif ((int) $children->child_count > 0) {
191
                $badge = 'info';
192
            } else {
193
                $badge = 'secondary';
194
            }
195
196
            $row->child_count = (int) $children->child_count;
197
            $row->badge       = $badge;
198
199
            $list[] = $row;
200
        }
201
202
        return $list;
203
    }
204
205
    /**
206
     * How many children does place have?  How many have co-ordinates?
207
     *
208
     * @param int $parent_id
209
     *
210
     * @return stdClass
211
     */
212
    private function childLocationStatus(int $parent_id): stdClass
213
    {
214
        $prefix = DB::connection()->getTablePrefix();
215
216
        $expression =
217
            $prefix . 'p0.pl_place IS NOT NULL AND ' . $prefix . 'p0.pl_lati IS NULL OR ' .
218
            $prefix . 'p1.pl_place IS NOT NULL AND ' . $prefix . 'p1.pl_lati IS NULL OR ' .
219
            $prefix . 'p2.pl_place IS NOT NULL AND ' . $prefix . 'p2.pl_lati IS NULL OR ' .
220
            $prefix . 'p3.pl_place IS NOT NULL AND ' . $prefix . 'p3.pl_lati IS NULL OR ' .
221
            $prefix . 'p4.pl_place IS NOT NULL AND ' . $prefix . 'p4.pl_lati IS NULL OR ' .
222
            $prefix . 'p5.pl_place IS NOT NULL AND ' . $prefix . 'p5.pl_lati IS NULL OR ' .
223
            $prefix . 'p6.pl_place IS NOT NULL AND ' . $prefix . 'p6.pl_lati IS NULL OR ' .
224
            $prefix . 'p7.pl_place IS NOT NULL AND ' . $prefix . 'p7.pl_lati IS NULL OR ' .
225
            $prefix . 'p8.pl_place IS NOT NULL AND ' . $prefix . 'p8.pl_lati IS NULL OR ' .
226
            $prefix . 'p9.pl_place IS NOT NULL AND ' . $prefix . 'p9.pl_lati IS NULL';
227
228
        return DB::table('placelocation AS p0')
229
            ->leftJoin('placelocation AS p1', 'p1.pl_parent_id', '=', 'p0.pl_id')
230
            ->leftJoin('placelocation AS p2', 'p2.pl_parent_id', '=', 'p1.pl_id')
231
            ->leftJoin('placelocation AS p3', 'p3.pl_parent_id', '=', 'p2.pl_id')
232
            ->leftJoin('placelocation AS p4', 'p4.pl_parent_id', '=', 'p3.pl_id')
233
            ->leftJoin('placelocation AS p5', 'p5.pl_parent_id', '=', 'p4.pl_id')
234
            ->leftJoin('placelocation AS p6', 'p6.pl_parent_id', '=', 'p5.pl_id')
235
            ->leftJoin('placelocation AS p7', 'p7.pl_parent_id', '=', 'p6.pl_id')
236
            ->leftJoin('placelocation AS p8', 'p8.pl_parent_id', '=', 'p7.pl_id')
237
            ->leftJoin('placelocation AS p9', 'p9.pl_parent_id', '=', 'p8.pl_id')
238
            ->where('p0.pl_parent_id', '=', $parent_id)
239
            ->select([new Expression('COUNT(*) AS child_count'), new Expression('SUM(' . $expression . ') AS no_coord')])
240
            ->first();
241
    }
242
243
    /**
244
     * Is a place name used in any tree?
245
     *
246
     * @param string $place_name
247
     *
248
     * @return bool
249
     */
250
    private function isLocationActive(string $place_name): bool
251
    {
252
        $places = explode(Gedcom::PLACE_SEPARATOR, $place_name);
253
254
        $query = DB::table('places AS p0')
255
            ->where('p0.p_place', '=', $places[0])
256
            ->select(['pl0.*']);
257
258
        array_shift($places);
259
260
        foreach ($places as $n => $place) {
261
            $query->join('places AS p' . ($n + 1), static function (JoinClause $join) use ($n, $place): void {
262
                $join
263
                    ->on('p' . ($n + 1) . '.p_id', '=', 'p' . $n . '.p_parent_id')
264
                    ->where('p' . ($n + 1) . '.p_place', '=', $place);
265
            });
266
        }
267
268
        return $query->exists();
269
    }
270
271
    /**
272
     * @param ServerRequestInterface $request
273
     *
274
     * @return ResponseInterface
275
     */
276
    public function mapDataEdit(ServerRequestInterface $request): ResponseInterface
277
    {
278
        $parent_id = (int) $request->getQueryParams()['parent_id'];
279
        $place_id  = (int) $request->getQueryParams()['place_id'];
280
        $hierarchy = $this->getHierarchy($place_id);
281
        $fqpn      = $hierarchy === [] ? '' : $hierarchy[0]->fqpn;
282
        $location  = new Location($fqpn);
283
284
        if ($location->id() !== 0) {
285
            $lat = $location->latitude();
286
            $lng = $location->longitude();
287
            $id  = $place_id;
288
        } else {
289
            $lat = '';
290
            $lng = '';
291
            $id  = $parent_id;
292
        }
293
294
        $title = e($location->locationName());
295
296
        $breadcrumbs = [
297
            route(ControlPanel::class) => I18N::translate('Control panel'),
298
            route('map-data')            => I18N::translate('Geographic data'),
299
        ];
300
301
        foreach ($hierarchy as $row) {
302
            $breadcrumbs[route('map-data', ['parent_id' => $row->pl_id])] = $row->pl_place;
303
        }
304
305
        if ($place_id === 0) {
306
            $breadcrumbs[] = I18N::translate('Add');
307
        } else {
308
            $breadcrumbs[] = I18N::translate('Edit');
309
            $title         .= ' — ' . I18N::translate('Edit');
310
        }
311
312
        return $this->viewResponse('admin/location-edit', [
313
            'breadcrumbs' => $breadcrumbs,
314
            'title'       => $title,
315
            'location'    => $location,
316
            'place_id'    => $place_id,
317
            'parent_id'   => $parent_id,
318
            'hierarchy'   => $hierarchy,
319
            'lat'         => $lat,
320
            'lng'         => $lng,
321
            'ref'         => $id,
322
            'data'        => $this->mapLocationData($id),
323
        ]);
324
    }
325
326
    /**
327
     * @param int $id
328
     *
329
     * @return array
330
     */
331
    private function mapLocationData(int $id): array
332
    {
333
        $row = DB::table('placelocation')
334
            ->where('pl_id', '=', $id)
335
            ->first();
336
337
        if ($row === null) {
338
            $json = [
339
                'zoom'        => 2,
340
                'coordinates' => [
341
                    0.0,
342
                    0.0,
343
                ],
344
            ];
345
        } else {
346
            $json = [
347
                'zoom'        => (int) $row->pl_zoom ?: 2,
348
                'coordinates' => [
349
                    (float) strtr($row->pl_lati ?? '0', ['N' => '', 'S' => '-', ',' => '.']),
350
                    (float) strtr($row->pl_long ?? '0', ['E' => '', 'W' => '-', ',' => '.']),
351
                ],
352
            ];
353
        }
354
355
        return $json;
356
    }
357
358
    /**
359
     * @param ServerRequestInterface $request
360
     *
361
     * @return ResponseInterface
362
     */
363
    public function mapDataSave(ServerRequestInterface $request): ResponseInterface
364
    {
365
        $parent_id = (int) $request->getQueryParams()['parent_id'];
366
        $place_id  = (int) $request->getQueryParams()['place_id'];
367
        $lat       = round($request->getParsedBody()['new_place_lati'], 5); // 5 decimal places (locate to within about 1 metre)
368
        $lat       = ($lat < 0 ? 'S' : 'N') . abs($lat);
369
        $lng       = round($request->getParsedBody()['new_place_long'], 5);
370
        $lng       = ($lng < 0 ? 'W' : 'E') . abs($lng);
371
        $hierarchy = $this->getHierarchy($parent_id);
372
        $level     = count($hierarchy);
373
        $icon      = $request->getParsedBody()['icon'];
374
        $zoom      = (int) $request->getParsedBody()['new_zoom_factor'];
375
376
        if ($place_id === 0) {
377
            $place_id = 1 + (int) DB::table('placelocation')->max('pl_id');
378
379
            DB::table('placelocation')->insert([
380
                'pl_id'        => $place_id,
381
                'pl_parent_id' => $parent_id,
382
                'pl_level'     => $level,
383
                'pl_place'     => $request->getParsedBody()['new_place_name'],
384
                'pl_lati'      => $lat,
385
                'pl_long'      => $lng,
386
                'pl_zoom'      => $zoom,
387
                'pl_icon'      => $icon,
388
            ]);
389
        } else {
390
            DB::table('placelocation')
391
                ->where('pl_id', '=', $place_id)
392
                ->update([
393
                    'pl_place' => $request->getParsedBody()['new_place_name'],
394
                    'pl_lati'  => $lat,
395
                    'pl_long'  => $lng,
396
                    'pl_zoom'  => $zoom,
397
                    'pl_icon'  => $icon,
398
                ]);
399
        }
400
401
        FlashMessages::addMessage(
402
            I18N::translate(
403
                'The details for “%s” have been updated.',
404
                $request->getParsedBody()['new_place_name']
405
            ),
406
            'success'
407
        );
408
409
        $url = route('map-data', ['parent_id' => $parent_id]);
410
411
        return redirect($url);
412
    }
413
414
    /**
415
     * @param ServerRequestInterface $request
416
     *
417
     * @return ResponseInterface
418
     */
419
    public function mapDataDelete(ServerRequestInterface $request): ResponseInterface
420
    {
421
        $place_id  = (int) $request->getQueryParams()['place_id'];
422
        $parent_id = (int) $request->getQueryParams()['parent_id'];
423
424
        try {
425
            DB::table('placelocation')
426
                ->where('pl_id', '=', $place_id)
427
                ->delete();
428
        } catch (Exception $ex) {
429
            FlashMessages::addMessage(
430
                I18N::translate('Location not removed: this location contains sub-locations'),
431
                'danger'
432
            );
433
        }
434
        // If after deleting there are no more places at this level then go up a level
435
        $children = DB::table('placelocation')
436
            ->where('pl_parent_id', '=', $parent_id)
437
            ->count();
438
439
        if ($children === 0) {
440
            $parent_id = (int) DB::table('placelocation')
441
                ->where('pl_id', '=', $parent_id)
442
                ->value('pl_parent_id');
443
        }
444
445
        $url = route('map-data', ['parent_id' => $parent_id]);
446
447
        return redirect($url);
448
    }
449
450
    /**
451
     * @param ServerRequestInterface $request
452
     *
453
     * @return ResponseInterface
454
     */
455
    public function exportLocations(ServerRequestInterface $request): ResponseInterface
456
    {
457
        $parent_id = (int) $request->getQueryParams()['parent_id'];
458
        $format    = $request->getQueryParams()['format'];
459
        $maxlevel  = (int) DB::table('placelocation')->max('pl_level');
460
        $startfqpn = [];
461
        $hierarchy = $this->getHierarchy($parent_id);
462
463
        // Create the file name
464
        // $hierarchy[0] always holds the full placename
465
        $place_name = $hierarchy === [] ? 'Global' : $hierarchy[0]->fqpn;
466
        $place_name = str_replace(Gedcom::PLACE_SEPARATOR, '-', $place_name);
467
        $filename   = 'Places-' . preg_replace('/[^a-zA-Z0-9.-]/', '', $place_name);
468
469
        // Fill in the place names for the starting conditions
470
        foreach ($hierarchy as $level => $record) {
471
            $startfqpn[$level] = $record->pl_place;
472
        }
473
        $startfqpn = array_pad($startfqpn, $maxlevel + 1, '');
474
475
        // Generate an array containing the data to output
476
        $places = [];
477
        $this->buildLevel($parent_id, $startfqpn, $places);
478
479
        $places = array_filter($places, static function (array $place): bool {
480
            return $place['pl_long'] !== 0.0 && $place['pl_lati'] !== 0.0;
481
        });
482
483
        if ($format === 'csv') {
484
            // Create the header line for the output file (always English)
485
            $header = [
486
                I18N::translate('Level'),
487
            ];
488
489
            for ($i = 0; $i <= $maxlevel; $i++) {
490
                $header[] = 'Place' . ($i + 1);
491
            }
492
493
            $header[] = 'Longitude';
494
            $header[] = 'Latitude';
495
            $header[] = 'Zoom';
496
            $header[] = 'Icon';
497
498
            return $this->exportCSV($filename . '.csv', $header, $places);
499
        }
500
501
        return $this->exportGeoJSON($filename . '.geojson', $places, $maxlevel);
502
    }
503
504
    /**
505
     * @param int   $parent_id
506
     * @param array $placename
507
     * @param array $places
508
     *
509
     * @return void
510
     * @throws Exception
511
     */
512
    private function buildLevel(int $parent_id, array $placename, array &$places): void
513
    {
514
        $level = array_search('', $placename, true);
515
516
        $rows = DB::table('placelocation')
517
            ->where('pl_parent_id', '=', $parent_id)
518
            ->orderBy('pl_place')
519
            ->get();
520
521
        foreach ($rows as $row) {
522
            $index             = (int) $row->pl_id;
523
            $placename[$level] = $row->pl_place;
524
            $places[]          = array_merge(['pl_level' => $row->pl_level], $placename, ['pl_long' => $row->pl_long, 'pl_lati' => $row->pl_lati, 'pl_zoom' => $row->pl_zoom, 'pl_icon' => $row->pl_icon]);
525
            $this->buildLevel($index, $placename, $places);
526
        }
527
    }
528
529
    /**
530
     * @param string     $filename
531
     * @param string[]   $columns
532
     * @param string[][] $places
533
     *
534
     * @return ResponseInterface
535
     */
536
    private function exportCSV(string $filename, array $columns, array $places): ResponseInterface
537
    {
538
        $resource = fopen('php://temp', 'rb+');
539
540
        fputcsv($resource, $columns, ';');
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $handle of fputcsv() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

540
        fputcsv(/** @scrutinizer ignore-type */ $resource, $columns, ';');
Loading history...
541
542
        foreach ($places as $place) {
543
            fputcsv($resource, $place, ';');
544
        }
545
546
        rewind($resource);
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $handle of rewind() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

546
        rewind(/** @scrutinizer ignore-type */ $resource);
Loading history...
547
548
        $filename = addcslashes($filename, '"');
549
550
        return response(stream_get_contents($resource))
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $handle of stream_get_contents() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

550
        return response(stream_get_contents(/** @scrutinizer ignore-type */ $resource))
Loading history...
551
            ->withHeader('Content-Type', 'text/csv; charset=utf-8')
552
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
553
    }
554
555
    /**
556
     * @param string $filename
557
     * @param array  $rows
558
     * @param int    $maxlevel
559
     *
560
     * @return ResponseInterface
561
     */
562
    private function exportGeoJSON(string $filename, array $rows, int $maxlevel): ResponseInterface
563
    {
564
        $geojson = [
565
            'type'     => 'FeatureCollection',
566
            'features' => [],
567
        ];
568
        foreach ($rows as $place) {
569
            $fqpn = implode(
570
                Gedcom::PLACE_SEPARATOR,
571
                array_reverse(
572
                    array_filter(
573
                        array_slice($place, 1, $maxlevel + 1)
574
                    )
575
                )
576
            );
577
578
            $geojson['features'][] = [
579
                'type'       => 'Feature',
580
                'geometry'   => [
581
                    'type'        => 'Point',
582
                    'coordinates' => [
583
                        $this->gedcom_service->readLongitude($place['pl_long'] ?? ''),
584
                        $this->gedcom_service->readLatitude($place['pl_lati'] ?? ''),
585
                    ],
586
                ],
587
                'properties' => [
588
                    'name' => $fqpn,
589
                ],
590
            ];
591
        }
592
593
        $filename = addcslashes($filename, '"');
594
595
        return response($geojson)
596
            ->withHeader('Content-Type', 'application/vnd.geo+json')
597
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
598
    }
599
600
    /**
601
     * @param ServerRequestInterface $request
602
     *
603
     * @return ResponseInterface
604
     */
605
    public function importLocations(ServerRequestInterface $request): ResponseInterface
606
    {
607
        $data_filesystem = $request->getAttribute('filesystem.data');
608
        assert($data_filesystem instanceof FilesystemInterface);
609
610
        $data_filesystem_name = $request->getAttribute('filesystem.data.name');
611
        assert(is_string($data_filesystem_name));
612
613
        $parent_id = (int) $request->getQueryParams()['parent_id'];
614
615
        $files = Collection::make($data_filesystem->listContents('places'))
616
            ->filter(static function (array $metadata): bool {
617
                return $metadata['extension'] === 'csv' || $metadata['extension'] === 'geojson';
618
            })
619
            ->map(static function (array $metadata): string {
620
                return $metadata['basename'];
621
            })
622
            ->sort();
623
624
        return $this->viewResponse('admin/map-import-form', [
625
            'place_folder' => $data_filesystem_name . self::PLACES_FOLDER,
626
            'title'        => I18N::translate('Import geographic data'),
627
            'parent_id'    => $parent_id,
628
            'files'        => $files,
629
        ]);
630
    }
631
632
    /**
633
     * This function assumes the input file layout is
634
     * level followed by a variable number of placename fields
635
     * followed by Longitude, Latitude, Zoom & Icon
636
     *
637
     * @param ServerRequestInterface $request
638
     *
639
     * @return ResponseInterface
640
     * @throws Exception
641
     */
642
    public function importLocationsAction(ServerRequestInterface $request): ResponseInterface
643
    {
644
        $data_filesystem = $request->getAttribute('filesystem.data');
645
        assert($data_filesystem instanceof FilesystemInterface);
646
647
        $serverfile     = $request->getParsedBody()['serverfile'] ?? '';
648
        $options        = $request->getParsedBody()['import-options'] ?? '';
649
        $clear_database = (bool) ($request->getParsedBody()['cleardatabase'] ?? false);
650
        $local_file     = $request->getUploadedFiles()['localfile'] ?? null;
651
652
        $places      = [];
653
        $field_names = [
654
            'pl_level',
655
            'pl_long',
656
            'pl_lati',
657
            'pl_zoom',
658
            'pl_icon',
659
            'fqpn',
660
        ];
661
662
        $url = route('map-data', ['parent_id' => 0]);
663
664
        $fp = false;
665
666
        if ($serverfile !== '' && $data_filesystem->has(self::PLACES_FOLDER . $serverfile)) {
667
            // first choice is file on server
668
            $fp = $data_filesystem->readStream(self::PLACES_FOLDER . $serverfile);
669
        } elseif ($local_file instanceof UploadedFileInterface && $local_file->getError() === UPLOAD_ERR_OK) {
670
            // 2nd choice is local file
671
            $fp = $local_file->getStream()->detach();
672
        }
673
674
        if ($fp === false) {
675
            return redirect($url);
676
        }
677
678
        $string = stream_get_contents($fp);
679
680
        // Check the file type
681
        if (stripos($string, 'FeatureCollection') !== false) {
682
            $input_array = json_decode($string, false);
683
684
            foreach ($input_array->features as $feature) {
685
                $places[] = array_combine($field_names, [
686
                    $feature->properties->level ?? substr_count($feature->properties->name, ','),
687
                    $this->gedcom_service->writeLongitude($feature->geometry->coordinates[0]),
688
                    $this->gedcom_service->writeLatitude($feature->geometry->coordinates[1]),
689
                    $feature->properties->zoom ?? null,
690
                    $feature->properties->icon ?? null,
691
                    $feature->properties->name,
692
                ]);
693
            }
694
        } else {
695
            rewind($fp);
696
            while (($row = fgetcsv($fp, 0, ';')) !== false) {
697
                // Skip the header
698
                if (!is_numeric($row[0])) {
699
                    continue;
700
                }
701
702
                $level = (int) $row[0];
703
                $count = count($row);
704
705
                // convert separate place fields into a comma separated placename
706
                $fqdn = implode(Gedcom::PLACE_SEPARATOR, array_reverse(array_slice($row, 1, 1 + $level)));
707
708
                $places[] = [
709
                    'pl_level' => $level,
710
                    'pl_long'  => $row[$count - 4],
711
                    'pl_lati'  => $row[$count - 3],
712
                    'pl_zoom'  => $row[$count - 2],
713
                    'pl_icon'  => $row[$count - 1],
714
                    'fqpn'     => $fqdn,
715
                ];
716
            }
717
        }
718
719
        fclose($fp);
720
721
        if ($clear_database) {
722
            DB::table('placelocation')->delete();
723
        }
724
725
        //process places
726
        $added   = 0;
727
        $updated = 0;
728
729
        foreach ($places as $place) {
730
            $location = new Location($place['fqpn']);
731
            $exists   = $location->exists();
732
733
            if ($options === 'update' && !$exists) {
734
                continue;
735
            }
736
737
            if (!$exists) {
738
                $added++;
739
            }
740
741
            if (!$exists || $options === 'update') {
742
                DB::table('placelocation')
743
                    ->where('pl_id', '=', $location->id())
744
                    ->update([
745
                        'pl_lati' => $place['pl_lati'],
746
                        'pl_long' => $place['pl_long'],
747
                        'pl_zoom' => $place['pl_zoom'] ?: null,
748
                        'pl_icon' => $place['pl_icon'] ?: null,
749
                    ]);
750
751
                $updated++;
752
            }
753
        }
754
        FlashMessages::addMessage(
755
            I18N::translate('locations updated: %s, locations added: %s', I18N::number($updated), I18N::number($added)),
756
            $added + $updated === 0 ? 'info' : 'success'
757
        );
758
759
        return redirect($url);
760
    }
761
762
    /**
763
     * @param ServerRequestInterface $request
764
     *
765
     * @return ResponseInterface
766
     */
767
    public function importLocationsFromTree(ServerRequestInterface $request): ResponseInterface
768
    {
769
        $ged  = $request->getParsedBody()['ged'] ?? '';
770
        $tree = $this->tree_service->all()->get($ged);
771
        assert($tree instanceof Tree);
772
773
        // Get all the places from the places table ...
774
        $places = DB::table('places AS p0')
775
            ->leftJoin('places AS p1', 'p1.p_id', '=', 'p0.p_parent_id')
776
            ->leftJoin('places AS p2', 'p2.p_id', '=', 'p1.p_parent_id')
777
            ->leftJoin('places AS p3', 'p3.p_id', '=', 'p2.p_parent_id')
778
            ->leftJoin('places AS p4', 'p4.p_id', '=', 'p3.p_parent_id')
779
            ->leftJoin('places AS p5', 'p5.p_id', '=', 'p4.p_parent_id')
780
            ->leftJoin('places AS p6', 'p6.p_id', '=', 'p5.p_parent_id')
781
            ->leftJoin('places AS p7', 'p7.p_id', '=', 'p6.p_parent_id')
782
            ->leftJoin('places AS p8', 'p8.p_id', '=', 'p7.p_parent_id')
783
            ->where('p0.p_file', '=', $tree->id())
784
            ->select([
785
                'p0.p_place AS place0',
786
                'p1.p_place AS place1',
787
                'p2.p_place AS place2',
788
                'p3.p_place AS place3',
789
                'p4.p_place AS place4',
790
                'p5.p_place AS place5',
791
                'p6.p_place AS place6',
792
                'p7.p_place AS place7',
793
                'p8.p_place AS place8',
794
            ])
795
            ->get()
796
            ->map(static function (stdClass $row): string {
797
                return implode(', ', array_filter((array) $row));
798
            });
799
800
        // ... and the placelocation table
801
        $locations = DB::table('placelocation AS p0')
802
            ->leftJoin('placelocation AS p1', 'p1.pl_id', '=', 'p0.pl_parent_id')
803
            ->leftJoin('placelocation AS p2', 'p2.pl_id', '=', 'p1.pl_parent_id')
804
            ->leftJoin('placelocation AS p3', 'p3.pl_id', '=', 'p2.pl_parent_id')
805
            ->leftJoin('placelocation AS p4', 'p4.pl_id', '=', 'p3.pl_parent_id')
806
            ->leftJoin('placelocation AS p5', 'p5.pl_id', '=', 'p4.pl_parent_id')
807
            ->leftJoin('placelocation AS p6', 'p6.pl_id', '=', 'p5.pl_parent_id')
808
            ->leftJoin('placelocation AS p7', 'p7.pl_id', '=', 'p6.pl_parent_id')
809
            ->leftJoin('placelocation AS p8', 'p8.pl_id', '=', 'p7.pl_parent_id')
810
            ->select([
811
                'p0.pl_id',
812
                'p0.pl_place AS place0',
813
                'p1.pl_place AS place1',
814
                'p2.pl_place AS place2',
815
                'p3.pl_place AS place3',
816
                'p4.pl_place AS place4',
817
                'p5.pl_place AS place5',
818
                'p6.pl_place AS place6',
819
                'p7.pl_place AS place7',
820
                'p8.pl_place AS place8',
821
            ])
822
            ->get()
823
            ->map(static function (stdClass $row): stdClass {
824
                $row->place = implode(', ', array_filter(array_slice((array) $row, 1)));
825
826
                return $row;
827
            })
828
            ->pluck('place', 'pl_id');
829
830
        // Compare the two ...
831
        $diff = $places->diff($locations);
832
833
        // ... and process the differences
834
        $inserted = 0;
835
        if ($diff->isNotEmpty()) {
836
            $nextRecordId = 1 + (int) DB::table('placelocation')->max('pl_id');
837
838
            foreach ($diff as $place) {
839
                // For Westminster, London, England, we must also create England and London, England
840
                $place_parts = explode(', ', $place);
841
                $count       = count($place_parts);
842
843
                try {
844
                    $parent_id = 0;
845
                    for ($i = $count - 1; $i >= 0; $i--) {
846
                        $parent   = implode(', ', array_slice($place_parts, $i));
847
                        $place_id = $locations->search($parent);
848
849
                        if ($place_id === false) {
850
                            DB::table('placelocation')->insert([
851
                                'pl_id'        => $nextRecordId,
852
                                'pl_parent_id' => $parent_id,
853
                                'pl_level'     => $count - $i,
854
                                'pl_place'     => $place_parts[$i],
855
                            ]);
856
857
                            $parent_id             = $nextRecordId;
858
                            $locations[$parent_id] = $parent;
859
                            $inserted++;
860
                            $nextRecordId++;
861
                        } else {
862
                            $parent_id = $place_id;
863
                        }
864
                    }
865
                } catch (QueryException $ex) {
866
                    // Duplicates are expected due to collation differences.  e.g. Quebec / Québec
867
                }
868
            }
869
        }
870
871
        FlashMessages::addMessage(I18N::plural('%s location has been imported.', '%s locations have been imported.', $inserted, I18N::number($inserted)), 'success');
872
873
        $url = route('map-data');
874
875
        return redirect($url);
876
    }
877
}
878