Completed
Push — master ( 57ccb8...cd9fd3 )
by Greg
12:47 queued 06:44
created

LocationController::importLocationsAction()   D

Complexity

Conditions 19
Paths 123

Size

Total Lines 121
Code Lines 74

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 19
eloc 74
c 2
b 0
f 0
nc 123
nop 1
dl 0
loc 121
rs 4.325

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
            $title = e($location->locationName());
289
        } else {
290
            // Add a place
291
            $lat       = '';
292
            $lng       = '';
293
            $id        = $parent_id;
294
            if ($parent_id === 0) {
295
                // We're at the global level so create a minimal
296
                // place for the page title and breadcrumbs
297
                $title         =  I18N::translate('World');
298
                $hierarchy     =  [];
299
            } else {
300
                $hierarchy = $this->getHierarchy($parent_id);
301
                $tmp       = new Location($hierarchy[0]->fqpn);
302
                $title     = e($tmp->locationName());
303
            }
304
        }
305
306
        $breadcrumbs = [
307
            route(ControlPanel::class) => I18N::translate('Control panel'),
308
            route('map-data')          => I18N::translate('Geographic data'),
309
        ];
310
311
        foreach ($hierarchy as $row) {
312
            $breadcrumbs[route('map-data', ['parent_id' => $row->pl_id])] = e($row->pl_place);
313
        }
314
315
        if ($place_id === 0) {
316
            $breadcrumbs[] = I18N::translate('Add');
317
            $title         .= ' — ' . I18N::translate('Add');
318
        } else {
319
            $breadcrumbs[] = I18N::translate('Edit');
320
            $title         .= ' — ' . I18N::translate('Edit');
321
        }
322
323
        return $this->viewResponse('admin/location-edit', [
324
            'breadcrumbs' => $breadcrumbs,
325
            'title'       => $title,
326
            'location'    => $location,
327
            'place_id'    => $place_id,
328
            'parent_id'   => $parent_id,
329
            'lat'         => $lat,
330
            'lng'         => $lng,
331
            'ref'         => $id,
332
            'data'        => $this->mapLocationData($id),
333
        ]);
334
    }
335
336
    /**
337
     * @param int $id
338
     *
339
     * @return array
340
     */
341
    private function mapLocationData(int $id): array
342
    {
343
        $row = DB::table('placelocation')
344
            ->where('pl_id', '=', $id)
345
            ->first();
346
347
        if ($row === null) {
348
            $json = [
349
                'zoom'        => 2,
350
                'coordinates' => [
351
                    0.0,
352
                    0.0,
353
                ],
354
            ];
355
        } else {
356
            $json = [
357
                'zoom'        => (int) $row->pl_zoom ?: 2,
358
                'coordinates' => [
359
                    (float) strtr($row->pl_lati ?? '0', ['N' => '', 'S' => '-', ',' => '.']),
360
                    (float) strtr($row->pl_long ?? '0', ['E' => '', 'W' => '-', ',' => '.']),
361
                ],
362
            ];
363
        }
364
365
        return $json;
366
    }
367
368
    /**
369
     * @param ServerRequestInterface $request
370
     *
371
     * @return ResponseInterface
372
     */
373
    public function mapDataSave(ServerRequestInterface $request): ResponseInterface
374
    {
375
        $params = (array) $request->getParsedBody();
376
377
        $parent_id = (int) $request->getQueryParams()['parent_id'];
378
        $place_id  = (int) $request->getQueryParams()['place_id'];
379
        $lat       = round($params['new_place_lati'], 5); // 5 decimal places (locate to within about 1 metre)
380
        $lat       = ($lat < 0 ? 'S' : 'N') . abs($lat);
381
        $lng       = round($params['new_place_long'], 5);
382
        $lng       = ($lng < 0 ? 'W' : 'E') . abs($lng);
383
        $hierarchy = $this->getHierarchy($parent_id);
384
        $level     = count($hierarchy);
385
        $icon      = $params['icon'];
386
        $zoom      = (int) $params['new_zoom_factor'];
387
388
        if ($place_id === 0) {
389
            $place_id = 1 + (int) DB::table('placelocation')->max('pl_id');
390
391
            DB::table('placelocation')->insert([
392
                'pl_id'        => $place_id,
393
                'pl_parent_id' => $parent_id,
394
                'pl_level'     => $level,
395
                'pl_place'     => $params['new_place_name'],
396
                'pl_lati'      => $lat,
397
                'pl_long'      => $lng,
398
                'pl_zoom'      => $zoom,
399
                'pl_icon'      => $icon,
400
            ]);
401
        } else {
402
            DB::table('placelocation')
403
                ->where('pl_id', '=', $place_id)
404
                ->update([
405
                    'pl_place' => $params['new_place_name'],
406
                    'pl_lati'  => $lat,
407
                    'pl_long'  => $lng,
408
                    'pl_zoom'  => $zoom,
409
                    'pl_icon'  => $icon,
410
                ]);
411
        }
412
413
        FlashMessages::addMessage(
414
            I18N::translate(
415
                'The details for “%s” have been updated.',
416
                e($params['new_place_name'])
417
            ),
418
            'success'
419
        );
420
421
        $url = route('map-data', ['parent_id' => $parent_id]);
422
423
        return redirect($url);
424
    }
425
426
    /**
427
     * @param ServerRequestInterface $request
428
     *
429
     * @return ResponseInterface
430
     */
431
    public function mapDataDelete(ServerRequestInterface $request): ResponseInterface
432
    {
433
        $place_id  = (int) $request->getQueryParams()['place_id'];
434
        $parent_id = (int) $request->getQueryParams()['parent_id'];
435
436
        try {
437
            DB::table('placelocation')
438
                ->where('pl_id', '=', $place_id)
439
                ->delete();
440
        } catch (Exception $ex) {
441
            FlashMessages::addMessage(
442
                I18N::translate('Location not removed: this location contains sub-locations'),
443
                'danger'
444
            );
445
        }
446
        // If after deleting there are no more places at this level then go up a level
447
        $children = DB::table('placelocation')
448
            ->where('pl_parent_id', '=', $parent_id)
449
            ->count();
450
451
        if ($children === 0) {
452
            $parent_id = (int) DB::table('placelocation')
453
                ->where('pl_id', '=', $parent_id)
454
                ->value('pl_parent_id');
455
        }
456
457
        $url = route('map-data', ['parent_id' => $parent_id]);
458
459
        return redirect($url);
460
    }
461
462
    /**
463
     * @param ServerRequestInterface $request
464
     *
465
     * @return ResponseInterface
466
     */
467
    public function exportLocations(ServerRequestInterface $request): ResponseInterface
468
    {
469
        $parent_id = (int) $request->getQueryParams()['parent_id'];
470
        $format    = $request->getQueryParams()['format'];
471
        $maxlevel  = (int) DB::table('placelocation')->max('pl_level');
472
        $startfqpn = [];
473
        $hierarchy = $this->getHierarchy($parent_id);
474
475
        // Create the file name
476
        // $hierarchy[0] always holds the full placename
477
        $place_name = $hierarchy === [] ? 'Global' : $hierarchy[0]->fqpn;
478
        $place_name = str_replace(Gedcom::PLACE_SEPARATOR, '-', $place_name);
479
        $filename   = 'Places-' . preg_replace('/[^a-zA-Z0-9.-]/', '', $place_name);
480
481
        // Fill in the place names for the starting conditions
482
        foreach ($hierarchy as $level => $record) {
483
            $startfqpn[$level] = $record->pl_place;
484
        }
485
        $startfqpn = array_pad($startfqpn, $maxlevel + 1, '');
486
487
        // Generate an array containing the data to output
488
        $places = [];
489
        $this->buildLevel($parent_id, $startfqpn, $places);
490
491
        $places = array_filter($places, static function (array $place): bool {
492
            return $place['pl_long'] !== 0.0 && $place['pl_lati'] !== 0.0;
493
        });
494
495
        if ($format === 'csv') {
496
            // Create the header line for the output file (always English)
497
            $header = [
498
                I18N::translate('Level'),
499
            ];
500
501
            for ($i = 0; $i <= $maxlevel; $i++) {
502
                $header[] = 'Place' . ($i + 1);
503
            }
504
505
            $header[] = 'Longitude';
506
            $header[] = 'Latitude';
507
            $header[] = 'Zoom';
508
            $header[] = 'Icon';
509
510
            return $this->exportCSV($filename . '.csv', $header, $places);
511
        }
512
513
        return $this->exportGeoJSON($filename . '.geojson', $places, $maxlevel);
514
    }
515
516
    /**
517
     * @param int   $parent_id
518
     * @param array $placename
519
     * @param array $places
520
     *
521
     * @return void
522
     * @throws Exception
523
     */
524
    private function buildLevel(int $parent_id, array $placename, array &$places): void
525
    {
526
        $level = array_search('', $placename, true);
527
528
        $rows = DB::table('placelocation')
529
            ->where('pl_parent_id', '=', $parent_id)
530
            ->orderBy('pl_place')
531
            ->get();
532
533
        foreach ($rows as $row) {
534
            $index             = (int) $row->pl_id;
535
            $placename[$level] = $row->pl_place;
536
            $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]);
537
            $this->buildLevel($index, $placename, $places);
538
        }
539
    }
540
541
    /**
542
     * @param string     $filename
543
     * @param string[]   $columns
544
     * @param string[][] $places
545
     *
546
     * @return ResponseInterface
547
     */
548
    private function exportCSV(string $filename, array $columns, array $places): ResponseInterface
549
    {
550
        $resource = fopen('php://temp', 'rb+');
551
552
        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

552
        fputcsv(/** @scrutinizer ignore-type */ $resource, $columns, ';');
Loading history...
553
554
        foreach ($places as $place) {
555
            fputcsv($resource, $place, ';');
556
        }
557
558
        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

558
        rewind(/** @scrutinizer ignore-type */ $resource);
Loading history...
559
560
        $filename = addcslashes($filename, '"');
561
562
        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

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