Passed
Push — master ( c9a927...3e5f5a )
by Greg
05:29
created

ImportThumbnailsController::imageDiff()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 36
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 15
nc 6
nop 3
dl 0
loc 36
rs 8.4444
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2020 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 Fisharebest\Webtrees\Cache;
23
use Fisharebest\Webtrees\Factory;
24
use Fisharebest\Webtrees\I18N;
25
use Fisharebest\Webtrees\Media;
26
use Fisharebest\Webtrees\Mime;
27
use Fisharebest\Webtrees\Services\PendingChangesService;
28
use Fisharebest\Webtrees\Services\SearchService;
29
use Fisharebest\Webtrees\Services\TreeService;
30
use Illuminate\Support\Collection;
31
use Intervention\Image\ImageManager;
32
use League\Flysystem\Filesystem;
33
use Psr\Http\Message\ResponseInterface;
34
use Psr\Http\Message\ServerRequestInterface;
35
36
use function abs;
37
use function app;
38
use function array_map;
39
use function assert;
40
use function dirname;
41
use function e;
42
use function explode;
43
use function glob;
44
use function implode;
45
use function intdiv;
46
use function is_file;
47
use function max;
48
use function response;
49
use function route;
50
use function str_replace;
51
use function stripos;
52
use function strlen;
53
use function strpos;
54
use function substr;
55
use function substr_compare;
56
use function view;
57
58
use const GLOB_NOSORT;
59
60
/**
61
 * Controller for importing custom thumbnails from webtrees 1.x.
62
 */
63
class ImportThumbnailsController extends AbstractAdminController
64
{
65
    private const FINGERPRINT_PIXELS = 10;
66
67
    /** @var PendingChangesService */
68
    private $pending_changes_service;
69
70
    /** @var SearchService */
71
    private $search_service;
72
73
    /** @var TreeService */
74
    private $tree_service;
75
76
    /**
77
     * ImportThumbnailsController constructor.
78
     *
79
     * @param PendingChangesService $pending_changes_service
80
     * @param SearchService         $search_service
81
     * @param TreeService           $tree_service
82
     */
83
    public function __construct(
84
        PendingChangesService $pending_changes_service,
85
        SearchService $search_service,
86
        TreeService $tree_service
87
    ) {
88
        $this->pending_changes_service = $pending_changes_service;
89
        $this->search_service          = $search_service;
90
        $this->tree_service            = $tree_service;
91
    }
92
93
    /**
94
     * Import custom thumbnails from webtres 1.x.
95
     *
96
     * @param ServerRequestInterface $request
97
     *
98
     * @return ResponseInterface
99
     */
100
    public function webtrees1Thumbnails(ServerRequestInterface $request): ResponseInterface
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

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

100
    public function webtrees1Thumbnails(/** @scrutinizer ignore-unused */ ServerRequestInterface $request): ResponseInterface

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
101
    {
102
        return $this->viewResponse('admin/webtrees1-thumbnails', [
103
            'title' => I18N::translate('Import custom thumbnails from webtrees version 1'),
104
        ]);
105
    }
106
107
    /**
108
     * Import custom thumbnails from webtres 1.x.
109
     *
110
     * @param ServerRequestInterface $request
111
     *
112
     * @return ResponseInterface
113
     */
114
    public function webtrees1ThumbnailsAction(ServerRequestInterface $request): ResponseInterface
115
    {
116
        $data_filesystem = $request->getAttribute('filesystem.data');
117
        assert($data_filesystem instanceof Filesystem);
118
119
        $params = (array) $request->getParsedBody();
120
121
        $thumbnail = $params['thumbnail'];
122
        $action    = $params['action'];
123
        $xrefs     = $params['xref'];
124
        $geds      = $params['ged'];
125
126
        if (!$data_filesystem->has($thumbnail)) {
127
            return response([]);
128
        }
129
130
        $media_objects = [];
131
132
        foreach ($xrefs as $key => $xref) {
133
            $tree            = $this->tree_service->all()->get($geds[$key]);
134
            $media_objects[] = Factory::media()->make($xref, $tree);
135
        }
136
137
        switch ($action) {
138
            case 'delete':
139
                $data_filesystem->delete($thumbnail);
140
                break;
141
142
            case 'add':
143
                $mime_type = $data_filesystem->getMimetype($thumbnail) ?: Mime::DEFAULT_TYPE;
144
                $directory = dirname($thumbnail, 2);
145
                $sha1      = sha1($data_filesystem->read($thumbnail));
0 ignored issues
show
Bug introduced by
It seems like $data_filesystem->read($thumbnail) can also be of type false; however, parameter $str of sha1() does only seem to accept string, 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

145
                $sha1      = sha1(/** @scrutinizer ignore-type */ $data_filesystem->read($thumbnail));
Loading history...
146
                $extension = explode('/', $mime_type)[1];
147
                $move_to   = $directory . '/' . $sha1 . '.' . $extension;
148
149
                $data_filesystem->rename($thumbnail, $move_to);
150
151
                foreach ($media_objects as $media_object) {
152
                    $prefix = $media_object->tree()->getPreference('MEDIA_DIRECTORY');
153
                    $gedcom = '1 FILE ' . substr($move_to, strlen($prefix)) . "\n2 FORM " . $extension;
154
155
                    if ($media_object->firstImageFile() === null) {
156
                        // The media object doesn't have an image.  Add this as a secondary file.
157
                        $media_object->createFact($gedcom, true);
158
                    } else {
159
                        // The media object already has an image.  Show this custom one in preference.
160
                        $gedcom = '0 @' . $media_object->xref() . "@ OBJE\n" . $gedcom;
161
                        foreach ($media_object->facts() as $fact) {
162
                            $gedcom .= "\n" . $fact->gedcom();
163
                        }
164
                        $media_object->updateRecord($gedcom, true);
165
                    }
166
167
                    // Accept the changes, to keep the filesystem in sync with the GEDCOM data.
168
                    $this->pending_changes_service->acceptRecord($media_object);
169
                }
170
                break;
171
        }
172
173
        return response([]);
174
    }
175
176
    /**
177
     * Import custom thumbnails from webtres 1.x.
178
     *
179
     * @param ServerRequestInterface $request
180
     *
181
     * @return ResponseInterface
182
     */
183
    public function webtrees1ThumbnailsData(ServerRequestInterface $request): ResponseInterface
184
    {
185
        $data_filesystem = $request->getAttribute('filesystem.data');
186
        assert($data_filesystem instanceof Filesystem);
187
188
        $start  = (int) $request->getQueryParams()['start'];
189
        $length = (int) $request->getQueryParams()['length'];
190
        $search = $request->getQueryParams()['search']['value'];
191
192
        // Fetch all thumbnails
193
        $thumbnails = Collection::make($data_filesystem->listContents('', true))
194
            ->filter(static function (array $metadata): bool {
195
                return $metadata['type'] === 'file' && strpos($metadata['path'], '/thumbs/') !== false;
196
            })
197
            ->map(static function (array $metadata): string {
198
                return $metadata['path'];
199
            });
200
201
        $recordsTotal = $thumbnails->count();
202
203
        if ($search !== '') {
204
            $thumbnails = $thumbnails->filter(static function (string $thumbnail) use ($search): bool {
205
                return stripos($thumbnail, $search) !== false;
206
            });
207
        }
208
209
        $recordsFiltered = $thumbnails->count();
210
211
        $data = $thumbnails
212
            ->slice($start, $length)
213
            ->map(function (string $thumbnail) use ($data_filesystem): array {
214
                // Turn each filename into a row for the table
215
                $original = $this->findOriginalFileFromThumbnail($thumbnail);
216
217
                $original_url  = route('unused-media-thumbnail', [
218
                    'path' => $original,
219
                    'w'    => 100,
220
                    'h'    => 100,
221
                ]);
222
                $thumbnail_url = route('unused-media-thumbnail', [
223
                    'path' => $thumbnail,
224
                    'w'    => 100,
225
                    'h'    => 100,
226
                ]);
227
228
                $difference = $this->imageDiff($data_filesystem, $thumbnail, $original);
229
230
                $media = $this->search_service->findMediaObjectsForMediaFile($original);
231
232
                $media_links = array_map(static function (Media $media): string {
233
                    return '<a href="' . e($media->url()) . '">' . $media->fullName() . '</a>';
234
                }, $media);
235
236
                $media_links = implode('<br>', $media_links);
237
238
                $action = view('admin/webtrees1-thumbnails-form', [
239
                    'difference' => $difference,
240
                    'media'      => $media,
241
                    'thumbnail'  => $thumbnail,
242
                ]);
243
244
                return [
245
                    '<img src="' . e($thumbnail_url) . '" title="' . e($thumbnail) . '">',
246
                    '<img src="' . e($original_url) . '" title="' . e($original) . '">',
247
                    $media_links,
248
                    I18N::percentage($difference / 100.0, 0),
249
                    $action,
250
                ];
251
            });
252
253
        return response([
254
            'draw'            => (int) $request->getQueryParams()['draw'],
255
            'recordsTotal'    => $recordsTotal,
256
            'recordsFiltered' => $recordsFiltered,
257
            'data'            => $data->values()->all(),
258
        ]);
259
    }
260
261
    /**
262
     * Find the original image that corresponds to a (webtrees 1.x) thumbnail file.
263
     *
264
     * @param string $thumbnail
265
     *
266
     * @return string
267
     */
268
    private function findOriginalFileFromThumbnail(string $thumbnail): string
269
    {
270
        // First option - a file with the same name
271
        $original = str_replace('/thumbs/', '/', $thumbnail);
272
273
        // Second option - a .PNG thumbnail for some other image type
274
        if (substr_compare($original, '.png', -4, 4) === 0) {
275
            $pattern = substr($original, 0, -3) . '*';
276
            $matches = glob($pattern, GLOB_NOSORT);
277
            if ($matches !== [] && is_file($matches[0])) {
278
                $original = $matches[0];
279
            }
280
        }
281
282
        return $original;
283
    }
284
285
    /**
286
     * Compare two images, and return a quantified difference.
287
     * 0 (different) ... 100 (same)
288
     *
289
     * @param Filesystem $data_filesystem
290
     * @param string     $thumbnail
291
     * @param string     $original
292
     *
293
     * @return int
294
     */
295
    private function imageDiff(Filesystem $data_filesystem, string $thumbnail, string $original): int
296
    {
297
        // The original filename was generated from the thumbnail filename.
298
        // It may not actually exist.
299
        if (!$data_filesystem->has($original)) {
300
            return 100;
301
        }
302
303
        $thumbnail_type = explode('/', $data_filesystem->getMimetype($thumbnail) ?: Mime::DEFAULT_TYPE)[0];
304
        $original_type  = explode('/', $data_filesystem->getMimetype($original) ?: Mime::DEFAULT_TYPE)[0];
305
306
        if ($thumbnail_type !== 'image') {
307
            // If the thumbnail file is not an image then similarity is unimportant.
308
            // Response with an exact match, so the GUI will recommend deleting it.
309
            return 100;
310
        }
311
312
        if ($original_type !== 'image') {
313
            // If the original file is not an image then similarity is unimportant .
314
            // Response with an exact mismatch, so the GUI will recommend importing it.
315
            return 0;
316
        }
317
318
        $pixels1 = $this->scaledImagePixels($data_filesystem, $thumbnail);
319
        $pixels2 = $this->scaledImagePixels($data_filesystem, $original);
320
321
        $max_difference = 0;
322
323
        foreach ($pixels1 as $x => $row) {
324
            foreach ($row as $y => $pixel) {
325
                $max_difference = max($max_difference, abs($pixel - $pixels2[$x][$y]));
326
            }
327
        }
328
329
        // The maximum difference is 255 (black versus white).
330
        return 100 - intdiv($max_difference * 100, 255);
331
    }
332
333
    /**
334
     * Scale an image to 10x10 and read the individual pixels.
335
     * This is a slow operation, add we will do it many times on
336
     * the "import webtrees 1 thumbnails" page so cache the results.
337
     *
338
     * @param Filesystem $filesystem
339
     * @param string     $path
340
     *
341
     * @return int[][]
342
     */
343
    private function scaledImagePixels(Filesystem $filesystem, string $path): array
344
    {
345
        $cache = app('cache.files');
346
        assert($cache instanceof Cache);
347
348
        return $cache->remember('pixels-' . $path, static function () use ($filesystem, $path): array {
349
            $blob    = $filesystem->read($path);
350
            $manager = new ImageManager();
351
            $image   = $manager->make($blob)->resize(self::FINGERPRINT_PIXELS, self::FINGERPRINT_PIXELS);
352
353
            $pixels = [];
354
            for ($x = 0; $x < self::FINGERPRINT_PIXELS; ++$x) {
355
                $pixels[$x] = [];
356
                for ($y = 0; $y < self::FINGERPRINT_PIXELS; ++$y) {
357
                    $pixel          = $image->pickColor($x, $y);
358
                    $pixels[$x][$y] = (int) (($pixel[0] + $pixel[1] + $pixel[2]) / 3);
359
                }
360
            }
361
362
            return $pixels;
363
        });
364
    }
365
}
366