1d4265d07SGreg Roach<?php 2d4265d07SGreg Roach 3d4265d07SGreg Roach/** 4d4265d07SGreg Roach * webtrees: online genealogy 55bfc6897SGreg Roach * Copyright (C) 2022 webtrees development team 6d4265d07SGreg Roach * This program is free software: you can redistribute it and/or modify 7d4265d07SGreg Roach * it under the terms of the GNU General Public License as published by 8d4265d07SGreg Roach * the Free Software Foundation, either version 3 of the License, or 9d4265d07SGreg Roach * (at your option) any later version. 10d4265d07SGreg Roach * This program is distributed in the hope that it will be useful, 11d4265d07SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 12d4265d07SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13d4265d07SGreg Roach * GNU General Public License for more details. 14d4265d07SGreg Roach * You should have received a copy of the GNU General Public License 1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>. 16d4265d07SGreg Roach */ 17d4265d07SGreg Roach 18d4265d07SGreg Roachdeclare(strict_types=1); 19d4265d07SGreg Roach 20d4265d07SGreg Roachnamespace Fisharebest\Webtrees\Services; 21d4265d07SGreg Roach 22b5c53c7fSGreg Roachuse Fisharebest\Webtrees\Exceptions\FileUploadException; 23d4265d07SGreg Roachuse Fisharebest\Webtrees\FlashMessages; 24d4265d07SGreg Roachuse Fisharebest\Webtrees\I18N; 25f7cf8a15SGreg Roachuse Fisharebest\Webtrees\Registry; 26d4265d07SGreg Roachuse Fisharebest\Webtrees\Tree; 27b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator; 28d4265d07SGreg Roachuse Illuminate\Database\Capsule\Manager as DB; 2913aa75d8SGreg Roachuse Illuminate\Database\Query\Expression; 30c6bad570SGreg Roachuse Illuminate\Database\Query\JoinClause; 3113aa75d8SGreg Roachuse Illuminate\Support\Collection; 32d4265d07SGreg Roachuse InvalidArgumentException; 33f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException; 34f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator; 35782714c2SGreg Roachuse League\Flysystem\FilesystemReader; 36f7cf8a15SGreg Roachuse League\Flysystem\StorageAttributes; 37d4265d07SGreg Roachuse Psr\Http\Message\ServerRequestInterface; 38d4265d07SGreg Roachuse RuntimeException; 39d4265d07SGreg Roach 40d4265d07SGreg Roachuse function array_combine; 41d4265d07SGreg Roachuse function array_diff; 42f9b64f46SGreg Roachuse function array_intersect; 4313aa75d8SGreg Roachuse function dirname; 44f9b64f46SGreg Roachuse function explode; 45d501c45dSGreg Roachuse function ini_get; 46d4265d07SGreg Roachuse function intdiv; 4781aa9d16SGreg Roachuse function is_float; 48d501c45dSGreg Roachuse function min; 49d4265d07SGreg Roachuse function pathinfo; 50d4265d07SGreg Roachuse function sha1; 51d4265d07SGreg Roachuse function sort; 52dec352c1SGreg Roachuse function str_contains; 5375e2e2b6SGreg Roachuse function strlen; 540ea5fc1cSGreg Roachuse function strtoupper; 5545fc2659SGreg Roachuse function strtr; 56d501c45dSGreg Roachuse function substr; 57d4265d07SGreg Roachuse function trim; 5846b03695SGreg Roach 59d4265d07SGreg Roachuse const PATHINFO_EXTENSION; 6081aa9d16SGreg Roachuse const PHP_INT_MAX; 61d4265d07SGreg Roachuse const UPLOAD_ERR_OK; 62d4265d07SGreg Roach 63d4265d07SGreg Roach/** 64d4265d07SGreg Roach * Managing media files. 65d4265d07SGreg Roach */ 66d4265d07SGreg Roachclass MediaFileService 67d4265d07SGreg Roach{ 6845fc2659SGreg Roach public const EXTENSION_TO_FORM = [ 690ea5fc1cSGreg Roach 'JPEG' => 'JPG', 700ea5fc1cSGreg Roach 'TIFF' => 'TIF', 7145fc2659SGreg Roach ]; 7245fc2659SGreg Roach 73c6bad570SGreg Roach private const IGNORE_FOLDERS = [ 74c6bad570SGreg Roach // Old versions of webtrees 75c6bad570SGreg Roach 'thumbs', 76c6bad570SGreg Roach 'watermarks', 77c6bad570SGreg Roach // Windows 78c6bad570SGreg Roach 'Thumbs.db', 79c6bad570SGreg Roach // Synology 80c6bad570SGreg Roach '@eaDir', 81c6bad570SGreg Roach // QNAP, 82c6bad570SGreg Roach '.@__thumb', 832c7d07c0SGreg Roach // WebDAV, 842c7d07c0SGreg Roach '_DAV', 85c6bad570SGreg Roach ]; 86c6bad570SGreg Roach 87d4265d07SGreg Roach /** 88d4265d07SGreg Roach * What is the largest file a user may upload? 89d4265d07SGreg Roach */ 90d4265d07SGreg Roach public function maxUploadFilesize(): string 91d4265d07SGreg Roach { 92d8809d62SGreg Roach $sizePostMax = $this->parseIniFileSize((string) ini_get('post_max_size')); 93d8809d62SGreg Roach $sizeUploadMax = $this->parseIniFileSize((string) ini_get('upload_max_filesize')); 94d501c45dSGreg Roach 95d501c45dSGreg Roach $bytes = min($sizePostMax, $sizeUploadMax); 96d4265d07SGreg Roach $kb = intdiv($bytes + 1023, 1024); 97d4265d07SGreg Roach 98d4265d07SGreg Roach return I18N::translate('%s KB', I18N::number($kb)); 99d4265d07SGreg Roach } 100d4265d07SGreg Roach 101d4265d07SGreg Roach /** 102d501c45dSGreg Roach * Returns the given size from an ini value in bytes. 103d501c45dSGreg Roach * 104284014f8SGreg Roach * @param string $size 105d501c45dSGreg Roach * 106d501c45dSGreg Roach * @return int 107d501c45dSGreg Roach */ 108284014f8SGreg Roach private function parseIniFileSize(string $size): int 109d501c45dSGreg Roach { 110d501c45dSGreg Roach $number = (int) $size; 111d501c45dSGreg Roach 11281aa9d16SGreg Roach $units = [ 11381aa9d16SGreg Roach 'g' => 1073741824, 11481aa9d16SGreg Roach 'G' => 1073741824, 11581aa9d16SGreg Roach 'm' => 1048576, 11681aa9d16SGreg Roach 'M' => 1048576, 11781aa9d16SGreg Roach 'k' => 1024, 11881aa9d16SGreg Roach 'K' => 1024, 11981aa9d16SGreg Roach ]; 12081aa9d16SGreg Roach 12181aa9d16SGreg Roach $number *= $units[substr($size, -1)] ?? 1; 12281aa9d16SGreg Roach 12381aa9d16SGreg Roach if (is_float($number)) { 12481aa9d16SGreg Roach // Probably a 32bit version of PHP, with an INI setting >= 2GB 12581aa9d16SGreg Roach return PHP_INT_MAX; 126d501c45dSGreg Roach } 12781aa9d16SGreg Roach 12881aa9d16SGreg Roach return $number; 129d501c45dSGreg Roach } 130d501c45dSGreg Roach 131d501c45dSGreg Roach /** 132d4265d07SGreg Roach * A list of media files not already linked to a media object. 133d4265d07SGreg Roach * 134d4265d07SGreg Roach * @param Tree $tree 135d4265d07SGreg Roach * 136bfe98399SGreg Roach * @return array<string> 137d4265d07SGreg Roach */ 1389458f20aSGreg Roach public function unusedFiles(Tree $tree): array 139d4265d07SGreg Roach { 140d4265d07SGreg Roach $used_files = DB::table('media_file') 141d4265d07SGreg Roach ->where('m_file', '=', $tree->id()) 142d4265d07SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 143d4265d07SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 144d4265d07SGreg Roach ->pluck('multimedia_file_refn') 145d4265d07SGreg Roach ->all(); 146d4265d07SGreg Roach 1479458f20aSGreg Roach $media_filesystem = $tree->mediaFilesystem(); 148782714c2SGreg Roach $disk_files = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all(); 149d4265d07SGreg Roach $unused_files = array_diff($disk_files, $used_files); 150d4265d07SGreg Roach 151d4265d07SGreg Roach sort($unused_files); 152d4265d07SGreg Roach 153d4265d07SGreg Roach return array_combine($unused_files, $unused_files); 154d4265d07SGreg Roach } 155d4265d07SGreg Roach 156d4265d07SGreg Roach /** 157d4265d07SGreg Roach * Store an uploaded file (or URL), either to be added to a media object 158d4265d07SGreg Roach * or to create a media object. 159d4265d07SGreg Roach * 160d4265d07SGreg Roach * @param ServerRequestInterface $request 161d4265d07SGreg Roach * 162d4265d07SGreg Roach * @return string The value to be stored in the 'FILE' field of the media object. 163f7cf8a15SGreg Roach * @throws FilesystemException 164d4265d07SGreg Roach */ 165d4265d07SGreg Roach public function uploadFile(ServerRequestInterface $request): string 166d4265d07SGreg Roach { 167b55cbc6bSGreg Roach $tree = Validator::attributes($request)->tree(); 168748dbe15SGreg Roach $file_location = Validator::parsedBody($request)->string('file_location'); 169d4265d07SGreg Roach 170d4265d07SGreg Roach switch ($file_location) { 171d4265d07SGreg Roach case 'url': 172748dbe15SGreg Roach $remote = Validator::parsedBody($request)->string('remote'); 173d4265d07SGreg Roach 174dec352c1SGreg Roach if (str_contains($remote, '://')) { 175d4265d07SGreg Roach return $remote; 176d4265d07SGreg Roach } 177d4265d07SGreg Roach 178d4265d07SGreg Roach return ''; 179d4265d07SGreg Roach 180d4265d07SGreg Roach case 'unused': 181748dbe15SGreg Roach $unused = Validator::parsedBody($request)->string('unused'); 182d4265d07SGreg Roach 1839458f20aSGreg Roach if ($tree->mediaFilesystem()->fileExists($unused)) { 184d4265d07SGreg Roach return $unused; 185d4265d07SGreg Roach } 186d4265d07SGreg Roach 187d4265d07SGreg Roach return ''; 188d4265d07SGreg Roach 189d4265d07SGreg Roach case 'upload': 190748dbe15SGreg Roach $folder = Validator::parsedBody($request)->string('folder'); 191748dbe15SGreg Roach $auto = Validator::parsedBody($request)->string('auto'); 192748dbe15SGreg Roach $new_file = Validator::parsedBody($request)->string('new_file'); 193d4265d07SGreg Roach 194b5c53c7fSGreg Roach $uploaded_file = $request->getUploadedFiles()['file'] ?? null; 195b5c53c7fSGreg Roach 196d4265d07SGreg Roach if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) { 197b5c53c7fSGreg Roach throw new FileUploadException($uploaded_file); 198d4265d07SGreg Roach } 199d4265d07SGreg Roach 200d4265d07SGreg Roach // The filename 201dec352c1SGreg Roach $new_file = strtr($new_file, ['\\' => '/']); 202dec352c1SGreg Roach if ($new_file !== '' && !str_contains($new_file, '/')) { 203d4265d07SGreg Roach $file = $new_file; 204d4265d07SGreg Roach } else { 205d4265d07SGreg Roach $file = $uploaded_file->getClientFilename(); 206d4265d07SGreg Roach } 207d4265d07SGreg Roach 208d4265d07SGreg Roach // The folder 209dec352c1SGreg Roach $folder = strtr($folder, ['\\' => '/']); 210d4265d07SGreg Roach $folder = trim($folder, '/'); 211d4265d07SGreg Roach if ($folder !== '') { 212d4265d07SGreg Roach $folder .= '/'; 213d4265d07SGreg Roach } 214d4265d07SGreg Roach 215d4265d07SGreg Roach // Generate a unique name for the file? 2169458f20aSGreg Roach if ($auto === '1' || $tree->mediaFilesystem()->fileExists($folder . $file)) { 217d4265d07SGreg Roach $folder = ''; 218d4265d07SGreg Roach $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION); 219d4265d07SGreg Roach $file = sha1((string) $uploaded_file->getStream()) . '.' . $extension; 220d4265d07SGreg Roach } 221d4265d07SGreg Roach 222d4265d07SGreg Roach try { 2239458f20aSGreg Roach $tree->mediaFilesystem()->writeStream($folder . $file, $uploaded_file->getStream()->detach()); 224d4265d07SGreg Roach 225d4265d07SGreg Roach return $folder . $file; 226*28d026adSGreg Roach } catch (RuntimeException | InvalidArgumentException) { 227d4265d07SGreg Roach FlashMessages::addMessage(I18N::translate('There was an error uploading your file.')); 228d4265d07SGreg Roach 229d4265d07SGreg Roach return ''; 230d4265d07SGreg Roach } 231d4265d07SGreg Roach } 232748dbe15SGreg Roach 233748dbe15SGreg Roach return ''; 234d4265d07SGreg Roach } 235d4265d07SGreg Roach 236d4265d07SGreg Roach /** 237d4265d07SGreg Roach * Convert the media file attributes into GEDCOM format. 238d4265d07SGreg Roach * 239d4265d07SGreg Roach * @param string $file 240d4265d07SGreg Roach * @param string $type 241d4265d07SGreg Roach * @param string $title 24245fc2659SGreg Roach * @param string $note 243d4265d07SGreg Roach * 244d4265d07SGreg Roach * @return string 245d4265d07SGreg Roach */ 24645fc2659SGreg Roach public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string 247d4265d07SGreg Roach { 248d4265d07SGreg Roach $gedcom = '1 FILE ' . $file; 24945fc2659SGreg Roach 2502bba966dSGreg Roach if (str_contains($file, '://')) { 2512bba966dSGreg Roach $format = ''; 2522bba966dSGreg Roach } else { 2530ea5fc1cSGreg Roach $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION)); 25445fc2659SGreg Roach $format = self::EXTENSION_TO_FORM[$format] ?? $format; 2552bba966dSGreg Roach } 25645fc2659SGreg Roach 25775e2e2b6SGreg Roach if ($format !== '' && strlen($format) <= 4) { 25845fc2659SGreg Roach $gedcom .= "\n2 FORM " . $format; 25945fc2659SGreg Roach } elseif ($type !== '') { 26045fc2659SGreg Roach $gedcom .= "\n2 FORM"; 261d4265d07SGreg Roach } 26245fc2659SGreg Roach 26345fc2659SGreg Roach if ($type !== '') { 26445fc2659SGreg Roach $gedcom .= "\n3 TYPE " . $type; 26545fc2659SGreg Roach } 26645fc2659SGreg Roach 267d4265d07SGreg Roach if ($title !== '') { 268d4265d07SGreg Roach $gedcom .= "\n2 TITL " . $title; 269d4265d07SGreg Roach } 270d4265d07SGreg Roach 27145fc2659SGreg Roach if ($note !== '') { 27245fc2659SGreg Roach // Convert HTML line endings to GEDCOM continuations 27345fc2659SGreg Roach $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]); 27445fc2659SGreg Roach } 27545fc2659SGreg Roach 276d4265d07SGreg Roach return $gedcom; 277d4265d07SGreg Roach } 27813aa75d8SGreg Roach 27913aa75d8SGreg Roach /** 28013aa75d8SGreg Roach * Fetch a list of all files on disk (in folders used by any tree). 28113aa75d8SGreg Roach * 282f7cf8a15SGreg Roach * @param FilesystemOperator $filesystem $filesystem to search 283f7cf8a15SGreg Roach * @param string $folder Root folder 28413aa75d8SGreg Roach * @param bool $subfolders Include subfolders 28513aa75d8SGreg Roach * 28636779af1SGreg Roach * @return Collection<int,string> 28713aa75d8SGreg Roach */ 288f7cf8a15SGreg Roach public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection 28913aa75d8SGreg Roach { 290f0448b68SGreg Roach try { 291f9b64f46SGreg Roach $files = $filesystem 292f9b64f46SGreg Roach ->listContents($folder, $subfolders) 293f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile()) 294f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 295f9b64f46SGreg Roach ->map(fn (StorageAttributes $attributes): string => $attributes->path()) 296f0448b68SGreg Roach ->toArray(); 297*28d026adSGreg Roach } catch (FilesystemException) { 298f0448b68SGreg Roach $files = []; 299f0448b68SGreg Roach } 300f7cf8a15SGreg Roach 301f0448b68SGreg Roach return new Collection($files); 30213aa75d8SGreg Roach } 30313aa75d8SGreg Roach 30413aa75d8SGreg Roach /** 30513aa75d8SGreg Roach * Fetch a list of all files on in the database. 30613aa75d8SGreg Roach * 30713aa75d8SGreg Roach * @param string $media_folder Root folder 30813aa75d8SGreg Roach * @param bool $subfolders Include subfolders 30913aa75d8SGreg Roach * 31036779af1SGreg Roach * @return Collection<int,string> 31113aa75d8SGreg Roach */ 31213aa75d8SGreg Roach public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection 31313aa75d8SGreg Roach { 31413aa75d8SGreg Roach $query = DB::table('media_file') 31513aa75d8SGreg Roach ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 31613aa75d8SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY') 31713aa75d8SGreg Roach //->where('multimedia_file_refn', 'LIKE', '%/%') 31813aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 31913aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 32013aa75d8SGreg Roach ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%') 32113aa75d8SGreg Roach ->select(new Expression('setting_value || multimedia_file_refn AS path')) 32213aa75d8SGreg Roach ->orderBy(new Expression('setting_value || multimedia_file_refn')); 32313aa75d8SGreg Roach 32413aa75d8SGreg Roach if (!$subfolders) { 32513aa75d8SGreg Roach $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%'); 32613aa75d8SGreg Roach } 32713aa75d8SGreg Roach 32813aa75d8SGreg Roach return $query->pluck('path'); 32913aa75d8SGreg Roach } 33013aa75d8SGreg Roach 33113aa75d8SGreg Roach /** 332f9b64f46SGreg Roach * Generate a list of all folders used by a tree. 333f9b64f46SGreg Roach * 334f9b64f46SGreg Roach * @param Tree $tree 335f9b64f46SGreg Roach * 33636779af1SGreg Roach * @return Collection<int,string> 337f9b64f46SGreg Roach * @throws FilesystemException 338f9b64f46SGreg Roach */ 339f9b64f46SGreg Roach public function mediaFolders(Tree $tree): Collection 340f9b64f46SGreg Roach { 3419458f20aSGreg Roach $folders = $tree->mediaFilesystem() 342782714c2SGreg Roach ->listContents('', FilesystemReader::LIST_DEEP) 343f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir()) 344f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 345f9b64f46SGreg Roach ->map(fn (StorageAttributes $attributes): string => $attributes->path()) 346f9b64f46SGreg Roach ->toArray(); 347f9b64f46SGreg Roach 348f9b64f46SGreg Roach return new Collection($folders); 349f9b64f46SGreg Roach } 350f9b64f46SGreg Roach 351f9b64f46SGreg Roach /** 35213aa75d8SGreg Roach * Generate a list of all folders in either the database or the filesystem. 35313aa75d8SGreg Roach * 354f7cf8a15SGreg Roach * @param FilesystemOperator $data_filesystem 35513aa75d8SGreg Roach * 35636779af1SGreg Roach * @return Collection<array-key,string> 357f7cf8a15SGreg Roach * @throws FilesystemException 35813aa75d8SGreg Roach */ 359f7cf8a15SGreg Roach public function allMediaFolders(FilesystemOperator $data_filesystem): Collection 36013aa75d8SGreg Roach { 36113aa75d8SGreg Roach $db_folders = DB::table('media_file') 362c6bad570SGreg Roach ->leftJoin('gedcom_setting', static function (JoinClause $join): void { 363c6bad570SGreg Roach $join 364c6bad570SGreg Roach ->on('gedcom_id', '=', 'm_file') 365c6bad570SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY'); 366c6bad570SGreg Roach }) 36713aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 36813aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 369c6bad570SGreg Roach ->select(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path")) 37013aa75d8SGreg Roach ->pluck('path') 37113aa75d8SGreg Roach ->map(static function (string $path): string { 37213aa75d8SGreg Roach return dirname($path) . '/'; 37313aa75d8SGreg Roach }); 37413aa75d8SGreg Roach 375c6bad570SGreg Roach $media_roots = DB::table('gedcom') 376c6bad570SGreg Roach ->leftJoin('gedcom_setting', static function (JoinClause $join): void { 377c6bad570SGreg Roach $join 378c6bad570SGreg Roach ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id') 379c6bad570SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY'); 380c6bad570SGreg Roach }) 381c6bad570SGreg Roach ->where('gedcom.gedcom_id', '>', '0') 382c6bad570SGreg Roach ->pluck(new Expression("COALESCE(setting_value, 'media/')")) 3838c627a69SGreg Roach ->uniqueStrict(); 38413aa75d8SGreg Roach 38513aa75d8SGreg Roach $disk_folders = new Collection($media_roots); 38613aa75d8SGreg Roach 38713aa75d8SGreg Roach foreach ($media_roots as $media_folder) { 388f9b64f46SGreg Roach $tmp = $data_filesystem 389782714c2SGreg Roach ->listContents($media_folder, FilesystemReader::LIST_DEEP) 390f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir()) 391f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 392f9b64f46SGreg Roach ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/') 393f7cf8a15SGreg Roach ->toArray(); 39413aa75d8SGreg Roach 39513aa75d8SGreg Roach $disk_folders = $disk_folders->concat($tmp); 39613aa75d8SGreg Roach } 39713aa75d8SGreg Roach 39813aa75d8SGreg Roach return $disk_folders->concat($db_folders) 3998c627a69SGreg Roach ->uniqueStrict() 4006edebcedSGreg Roach ->sort(I18N::comparator()) 40113aa75d8SGreg Roach ->mapWithKeys(static function (string $folder): array { 40213aa75d8SGreg Roach return [$folder => $folder]; 40313aa75d8SGreg Roach }); 40413aa75d8SGreg Roach } 405f7cf8a15SGreg Roach 406f7cf8a15SGreg Roach /** 407f9b64f46SGreg Roach * Ignore special media folders. 40808b5db2aSGreg Roach * 40908b5db2aSGreg Roach * @param string $path 41008b5db2aSGreg Roach * 41108b5db2aSGreg Roach * @return bool 412f7cf8a15SGreg Roach */ 413c6bad570SGreg Roach private function ignorePath(string $path): bool 414f7cf8a15SGreg Roach { 415d8809d62SGreg Roach return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== []; 416f7cf8a15SGreg Roach } 417d4265d07SGreg Roach} 418