Completed
Push — revert-2998-Geographic_data_ad... ( a6aa0c )
by Greg
15:02 queued 06:11
created

LocationController::mapDataEdit()   A

Complexity

Conditions 5
Paths 16

Size

Total Lines 47
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 35
c 2
b 0
f 0
nc 16
nop 1
dl 0
loc 47
rs 9.0488
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
        $params = (array) $request->getParsedBody();
366
367
        $parent_id = (int) $request->getQueryParams()['parent_id'];
368
        $place_id  = (int) $request->getQueryParams()['place_id'];
369
        $lat       = round($params['new_place_lati'], 5); // 5 decimal places (locate to within about 1 metre)
370
        $lat       = ($lat < 0 ? 'S' : 'N') . abs($lat);
371
        $lng       = round($params['new_place_long'], 5);
372
        $lng       = ($lng < 0 ? 'W' : 'E') . abs($lng);
373
        $hierarchy = $this->getHierarchy($parent_id);
374
        $level     = count($hierarchy);
375
        $icon      = $params['icon'];
376
        $zoom      = (int) $params['new_zoom_factor'];
377
378
        if ($place_id === 0) {
379
            $place_id = 1 + (int) DB::table('placelocation')->max('pl_id');
380
381
            DB::table('placelocation')->insert([
382
                'pl_id'        => $place_id,
383
                'pl_parent_id' => $parent_id,
384
                'pl_level'     => $level,
385
                'pl_place'     => $params['new_place_name'],
386
                'pl_lati'      => $lat,
387
                'pl_long'      => $lng,
388
                'pl_zoom'      => $zoom,
389
                'pl_icon'      => $icon,
390
            ]);
391
        } else {
392
            DB::table('placelocation')
393
                ->where('pl_id', '=', $place_id)
394
                ->update([
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
        }
402
403
        FlashMessages::addMessage(
404
            I18N::translate(
405
                'The details for “%s” have been updated.',
406
                $params['new_place_name']
407
            ),
408
            'success'
409
        );
410
411
        $url = route('map-data', ['parent_id' => $parent_id]);
412
413
        return redirect($url);
414
    }
415
416
    /**
417
     * @param ServerRequestInterface $request
418
     *
419
     * @return ResponseInterface
420
     */
421
    public function mapDataDelete(ServerRequestInterface $request): ResponseInterface
422
    {
423
        $place_id  = (int) $request->getQueryParams()['place_id'];
424
        $parent_id = (int) $request->getQueryParams()['parent_id'];
425
426
        try {
427
            DB::table('placelocation')
428
                ->where('pl_id', '=', $place_id)
429
                ->delete();
430
        } catch (Exception $ex) {
431
            FlashMessages::addMessage(
432
                I18N::translate('Location not removed: this location contains sub-locations'),
433
                'danger'
434
            );
435
        }
436
        // If after deleting there are no more places at this level then go up a level
437
        $children = DB::table('placelocation')
438
            ->where('pl_parent_id', '=', $parent_id)
439
            ->count();
440
441
        if ($children === 0) {
442
            $parent_id = (int) DB::table('placelocation')
443
                ->where('pl_id', '=', $parent_id)
444
                ->value('pl_parent_id');
445
        }
446
447
        $url = route('map-data', ['parent_id' => $parent_id]);
448
449
        return redirect($url);
450
    }
451
452
    /**
453
     * @param ServerRequestInterface $request
454
     *
455
     * @return ResponseInterface
456
     */
457
    public function exportLocations(ServerRequestInterface $request): ResponseInterface
458
    {
459
        $parent_id = (int) $request->getQueryParams()['parent_id'];
460
        $format    = $request->getQueryParams()['format'];
461
        $maxlevel  = (int) DB::table('placelocation')->max('pl_level');
462
        $startfqpn = [];
463
        $hierarchy = $this->getHierarchy($parent_id);
464
465
        // Create the file name
466
        // $hierarchy[0] always holds the full placename
467
        $place_name = $hierarchy === [] ? 'Global' : $hierarchy[0]->fqpn;
468
        $place_name = str_replace(Gedcom::PLACE_SEPARATOR, '-', $place_name);
469
        $filename   = 'Places-' . preg_replace('/[^a-zA-Z0-9.-]/', '', $place_name);
470
471
        // Fill in the place names for the starting conditions
472
        foreach ($hierarchy as $level => $record) {
473
            $startfqpn[$level] = $record->pl_place;
474
        }
475
        $startfqpn = array_pad($startfqpn, $maxlevel + 1, '');
476
477
        // Generate an array containing the data to output
478
        $places = [];
479
        $this->buildLevel($parent_id, $startfqpn, $places);
480
481
        $places = array_filter($places, static function (array $place): bool {
482
            return $place['pl_long'] !== 0.0 && $place['pl_lati'] !== 0.0;
483
        });
484
485
        if ($format === 'csv') {
486
            // Create the header line for the output file (always English)
487
            $header = [
488
                I18N::translate('Level'),
489
            ];
490
491
            for ($i = 0; $i <= $maxlevel; $i++) {
492
                $header[] = 'Place' . ($i + 1);
493
            }
494
495
            $header[] = 'Longitude';
496
            $header[] = 'Latitude';
497
            $header[] = 'Zoom';
498
            $header[] = 'Icon';
499
500
            return $this->exportCSV($filename . '.csv', $header, $places);
501
        }
502
503
        return $this->exportGeoJSON($filename . '.geojson', $places, $maxlevel);
504
    }
505
506
    /**
507
     * @param int   $parent_id
508
     * @param array $placename
509
     * @param array $places
510
     *
511
     * @return void
512
     * @throws Exception
513
     */
514
    private function buildLevel(int $parent_id, array $placename, array &$places): void
515
    {
516
        $level = array_search('', $placename, true);
517
518
        $rows = DB::table('placelocation')
519
            ->where('pl_parent_id', '=', $parent_id)
520
            ->orderBy('pl_place')
521
            ->get();
522
523
        foreach ($rows as $row) {
524
            $index             = (int) $row->pl_id;
525
            $placename[$level] = $row->pl_place;
526
            $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]);
527
            $this->buildLevel($index, $placename, $places);
528
        }
529
    }
530
531
    /**
532
     * @param string     $filename
533
     * @param string[]   $columns
534
     * @param string[][] $places
535
     *
536
     * @return ResponseInterface
537
     */
538
    private function exportCSV(string $filename, array $columns, array $places): ResponseInterface
539
    {
540
        $resource = fopen('php://temp', 'rb+');
541
542
        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

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

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

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