Passed
Push — master ( f30742...6cb597 )
by Greg
06:49 queued 12s
created

LocationController::importLocationsAction()   F

Complexity

Conditions 20
Paths 147

Size

Total Lines 120
Code Lines 74

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 20
eloc 74
nc 147
nop 1
dl 0
loc 120
rs 3.775
c 0
b 0
f 0

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

545
        fputcsv(/** @scrutinizer ignore-type */ $resource, $columns, ';');
Loading history...
546
547
        foreach ($places as $place) {
548
            fputcsv($resource, $place, ';');
549
        }
550
551
        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

551
        rewind(/** @scrutinizer ignore-type */ $resource);
Loading history...
552
553
        $filename = addcslashes($filename, '"');
554
555
        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

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