xref: /webtrees/app/Services/MediaFileService.php (revision 28d026ad36e53e5af3ffb5b483ee815e04b18ecf)
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