Passed
Push — master ( a61d86...f61677 )
by Greg
05:50
created

ImportThumbnailsController::imageDiff()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 36
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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

104
    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...
105
    {
106
        return $this->viewResponse('admin/webtrees1-thumbnails', [
107
            'title' => I18N::translate('Import custom thumbnails from webtrees version 1'),
108
        ]);
109
    }
110
111
    /**
112
     * Import custom thumbnails from webtres 1.x.
113
     *
114
     * @param ServerRequestInterface $request
115
     *
116
     * @return ResponseInterface
117
     */
118
    public function webtrees1ThumbnailsAction(ServerRequestInterface $request): ResponseInterface
119
    {
120
        $thumbnail = $request->getParsedBody()['thumbnail'];
121
        $action    = $request->getParsedBody()['action'];
122
        $xrefs     = $request->getParsedBody()['xref'];
123
        $geds      = $request->getParsedBody()['ged'];
124
125
        $media_objects = [];
126
127
        foreach ($xrefs as $key => $xref) {
128
            $tree            = $this->tree_service->all()->get($geds[$key]);
129
            $media_objects[] = Media::getInstance($xref, $tree);
130
        }
131
132
        $thumbnail = WT_DATA_DIR . $thumbnail;
133
134
        switch ($action) {
135
            case 'delete':
136
                if (file_exists($thumbnail)) {
137
                    unlink($thumbnail);
138
                }
139
                break;
140
141
            case 'add':
142
                $image_size = getimagesize($thumbnail);
143
                [, $extension] = explode('/', $image_size['mime']);
144
                $move_to = dirname($thumbnail, 2) . '/' . sha1_file($thumbnail) . '.' . $extension;
145
                rename($thumbnail, $move_to);
146
147
                foreach ($media_objects as $media_object) {
148
                    $prefix = WT_DATA_DIR . $media_object->tree()->getPreference('MEDIA_DIRECTORY');
149
                    $gedcom = '1 FILE ' . substr($move_to, strlen($prefix)) . "\n2 FORM " . $extension;
150
151
                    if ($media_object->firstImageFile() === null) {
152
                        // The media object doesn't have an image.  Add this as a secondary file.
153
                        $media_object->createFact($gedcom, true);
154
                    } else {
155
                        // The media object already has an image.  Show this custom one in preference.
156
                        $gedcom = '0 @' . $media_object->xref() . "@ OBJE\n" . $gedcom;
157
                        foreach ($media_object->facts() as $fact) {
158
                            $gedcom .= "\n" . $fact->gedcom();
159
                        }
160
                        $media_object->updateRecord($gedcom, true);
161
                    }
162
163
                    // Accept the changes, to keep the filesystem in sync with the GEDCOM data.
164
                    $this->pending_changes_service->acceptRecord($media_object);
165
                }
166
                break;
167
        }
168
169
        return response([]);
170
    }
171
172
    /**
173
     * Import custom thumbnails from webtres 1.x.
174
     *
175
     * @param ServerRequestInterface $request
176
     *
177
     * @return ResponseInterface
178
     */
179
    public function webtrees1ThumbnailsData(ServerRequestInterface $request): ResponseInterface
180
    {
181
        $data_filesystem = $request->getAttribute('filesystem.data');
182
        assert($data_filesystem instanceof Filesystem);
183
184
        $start  = (int) $request->getQueryParams()['start'];
185
        $length = (int) $request->getQueryParams()['length'];
186
        $search = $request->getQueryParams()['search']['value'];
187
188
        // Fetch all thumbnails
189
        $thumbnails = Collection::make($data_filesystem->listContents('', true))
190
            ->filter(static function (array $metadata): bool {
191
                return $metadata['type'] === 'file' && strpos($metadata['path'], '/thumbs/') !== false;
192
            })
193
            ->map(static function (array $metadata): string {
194
                return $metadata['path'];
195
            });
196
197
        $recordsTotal = $thumbnails->count();
198
199
        if ($search !== '') {
200
            $thumbnails = $thumbnails->filter(static function (string $thumbnail) use ($search): bool {
201
                return stripos($thumbnail, $search) !== false;
202
            });
203
        }
204
205
        $recordsFiltered = $thumbnails->count();
206
207
        $data = $thumbnails
208
            ->slice($start, $length)
209
            ->map(function (string $thumbnail) use ($data_filesystem): array {
210
                // Turn each filename into a row for the table
211
                $original = $this->findOriginalFileFromThumbnail($thumbnail);
212
213
                $original_url  = route('unused-media-thumbnail', [
214
                    'path' => $original,
215
                    'w'    => 100,
216
                    'h'    => 100,
217
                ]);
218
                $thumbnail_url = route('unused-media-thumbnail', [
219
                    'path' => $thumbnail,
220
                    'w'    => 100,
221
                    'h'    => 100,
222
                ]);
223
224
                $difference = $this->imageDiff($data_filesystem, $thumbnail, $original);
225
226
                $original_path  = substr($original, strlen(WT_DATA_DIR));
227
                $thumbnail_path = substr($thumbnail, strlen(WT_DATA_DIR));
228
229
                $media = $this->search_service->findMediaObjectsForMediaFile($original_path);
230
231
                $media_links = array_map(static function (Media $media): string {
232
                    return '<a href="' . e($media->url()) . '">' . $media->fullName() . '</a>';
233
                }, $media);
234
235
                $media_links = implode('<br>', $media_links);
236
237
                $action = view('admin/webtrees1-thumbnails-form', [
238
                    'difference' => $difference,
239
                    'media'      => $media,
240
                    'thumbnail'  => $thumbnail_path,
241
                ]);
242
243
                return [
244
                    '<img src="' . e($thumbnail_url) . '" title="' . e($thumbnail_path) . '">',
245
                    '<img src="' . e($original_url) . '" title="' . e($original_path) . '">',
246
                    $media_links,
247
                    I18N::percentage($difference / 100.0, 0),
248
                    $action,
249
                ];
250
            });
251
252
        return response([
253
            'draw'            => (int) $request->getQueryParams()['draw'],
254
            'recordsTotal'    => $recordsTotal,
255
            'recordsFiltered' => $recordsFiltered,
256
            'data'            => $data->values()->all(),
257
        ]);
258
    }
259
260
    /**
261
     * Find the original image that corresponds to a (webtrees 1.x) thumbnail file.
262
     *
263
     * @param string $thumbnail
264
     *
265
     * @return string
266
     */
267
    private function findOriginalFileFromThumbnail(string $thumbnail): string
268
    {
269
        // First option - a file with the same name
270
        $original = str_replace('/thumbs/', '/', $thumbnail);
271
272
        // Second option - a .PNG thumbnail for some other image type
273
        if (substr_compare($original, '.png', -4, 4) === 0) {
274
            $pattern = substr($original, 0, -3) . '*';
275
            $matches = glob($pattern, GLOB_NOSORT);
276
            if ($matches !== [] && is_file($matches[0])) {
277
                $original = $matches[0];
278
            }
279
        }
280
281
        return $original;
282
    }
283
284
    /**
285
     * Compare two images, and return a quantified difference.
286
     * 0 (different) ... 100 (same)
287
     *
288
     * @param Filesystem $data_filesystem
289
     * @param string     $thumbnail
290
     * @param string     $original
291
     *
292
     * @return int
293
     */
294
    private function imageDiff(Filesystem $data_filesystem, string $thumbnail, string $original): int
295
    {
296
        // The original filename was generated from the thumbnail filename.
297
        // It may not actually exist.
298
        if (!$data_filesystem->has($original)) {
299
            return 100;
300
        }
301
302
        $thumbnail_type = explode('/', $data_filesystem->getMimetype($thumbnail))[0];
0 ignored issues
show
Bug introduced by
It seems like $data_filesystem->getMimetype($thumbnail) can also be of type false; however, parameter $string of explode() 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

302
        $thumbnail_type = explode('/', /** @scrutinizer ignore-type */ $data_filesystem->getMimetype($thumbnail))[0];
Loading history...
303
        $original_type  = explode('/', $data_filesystem->getMimetype($original))[0];
304
305
        if ($thumbnail_type !== 'image') {
306
            // If the thumbnail file is not an image then similarity is unimportant.
307
            // Response with an exact match, so the GUI will recommend deleting it.
308
            return 100;
309
        }
310
311
        if ($original_type !== 'image') {
312
            // If the original file is not an image then similarity is unimportant .
313
            // Response with an exact mismatch, so the GUI will recommend importing it.
314
            return 0;
315
        }
316
317
        $pixels1 = $this->scaledImagePixels($data_filesystem, $thumbnail);
318
        $pixels2 = $this->scaledImagePixels($data_filesystem, $original);
319
320
        $max_difference = 0;
321
322
        foreach ($pixels1 as $x => $row) {
323
            foreach ($row as $y => $pixel) {
324
                $max_difference = max($max_difference, abs($pixel - $pixels2[$x][$y]));
325
            }
326
        }
327
328
        // The maximum difference is 255 (black versus white).
329
        return 100 - intdiv($max_difference * 100, 255);
330
    }
331
332
    /**
333
     * Scale an image to 10x10 and read the individual pixels.
334
     * This is a slow operation, add we will do it many times on
335
     * the "import wetbrees 1 thumbnails" page so cache the results.
336
     *
337
     * @param Filesystem $filesystem
338
     * @param string     $path
339
     *
340
     * @return int[][]
341
     */
342
    private function scaledImagePixels(Filesystem $filesystem, string $path): array
343
    {
344
        $cache = app('cache.files');
345
        assert($cache instanceof Cache);
346
347
        $cache_key = 'pixels-' . md5($path);
348
349
        return $cache->remember($cache_key, static function () use ($filesystem, $path): array {
350
            $blob    = $filesystem->read($path);
351
            $manager = new ImageManager();
352
            $image   = $manager->make($blob)->resize(self::FINGERPRINT_PIXELS, self::FINGERPRINT_PIXELS);
353
354
            $pixels = [];
355
            for ($x = 0; $x < self::FINGERPRINT_PIXELS; ++$x) {
356
                $pixels[$x] = [];
357
                for ($y = 0; $y < self::FINGERPRINT_PIXELS; ++$y) {
358
                    $pixel          = $image->pickColor($x, $y);
359
                    $pixels[$x][$y] = (int) (($pixel[0] + $pixel[1] + $pixel[2]) / 3);
360
                }
361
            }
362
363
            return $pixels;
364
        });
365
    }
366
}
367