xref: /webtrees/app/Services/MediaFileService.php (revision 2b44f6fbd2fb03540c31fbd23370a6d36a5f5c6d)
1d4265d07SGreg Roach<?php
2d4265d07SGreg Roach
3d4265d07SGreg Roach/**
4d4265d07SGreg Roach * webtrees: online genealogy
5d4265d07SGreg Roach * Copyright (C) 2019 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
15d4265d07SGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>.
16d4265d07SGreg Roach */
17d4265d07SGreg Roach
18d4265d07SGreg Roachdeclare(strict_types=1);
19d4265d07SGreg Roach
20d4265d07SGreg Roachnamespace Fisharebest\Webtrees\Services;
21d4265d07SGreg Roach
22d4265d07SGreg Roachuse Fisharebest\Webtrees\FlashMessages;
23d4265d07SGreg Roachuse Fisharebest\Webtrees\GedcomTag;
24d4265d07SGreg Roachuse Fisharebest\Webtrees\I18N;
25d4265d07SGreg Roachuse Fisharebest\Webtrees\Tree;
26d4265d07SGreg Roachuse Illuminate\Database\Capsule\Manager as DB;
2713aa75d8SGreg Roachuse Illuminate\Database\Query\Expression;
2813aa75d8SGreg Roachuse Illuminate\Support\Collection;
29d4265d07SGreg Roachuse InvalidArgumentException;
30a04bb9a2SGreg Roachuse League\Flysystem\FilesystemInterface;
31d4265d07SGreg Roachuse Psr\Http\Message\ServerRequestInterface;
32d4265d07SGreg Roachuse Psr\Http\Message\UploadedFileInterface;
33d4265d07SGreg Roachuse RuntimeException;
34d4265d07SGreg Roach
35d4265d07SGreg Roachuse function array_combine;
36d4265d07SGreg Roachuse function array_diff;
37d4265d07SGreg Roachuse function array_filter;
38d4265d07SGreg Roachuse function array_map;
39d4265d07SGreg Roachuse function assert;
4013aa75d8SGreg Roachuse function dirname;
41d501c45dSGreg Roachuse function ini_get;
42d4265d07SGreg Roachuse function intdiv;
43d501c45dSGreg Roachuse function min;
44d4265d07SGreg Roachuse function pathinfo;
4545fc2659SGreg Roachuse function preg_replace;
46d4265d07SGreg Roachuse function sha1;
47d4265d07SGreg Roachuse function sort;
48d4265d07SGreg Roachuse function str_replace;
49d4265d07SGreg Roachuse function strpos;
50d4265d07SGreg Roachuse function strtolower;
5145fc2659SGreg Roachuse function strtr;
52d501c45dSGreg Roachuse function substr;
53d4265d07SGreg Roachuse function trim;
54d4265d07SGreg Roach
55d4265d07SGreg Roachuse const PATHINFO_EXTENSION;
56d4265d07SGreg Roachuse const UPLOAD_ERR_OK;
57d4265d07SGreg Roach
58d4265d07SGreg Roach/**
59d4265d07SGreg Roach * Managing media files.
60d4265d07SGreg Roach */
61d4265d07SGreg Roachclass MediaFileService
62d4265d07SGreg Roach{
63d4265d07SGreg Roach    public const EDIT_RESTRICTIONS = [
64d4265d07SGreg Roach        'locked',
65d4265d07SGreg Roach    ];
66d4265d07SGreg Roach
67d4265d07SGreg Roach    public const PRIVACY_RESTRICTIONS = [
68d4265d07SGreg Roach        'none',
69d4265d07SGreg Roach        'privacy',
70d4265d07SGreg Roach        'confidential',
71d4265d07SGreg Roach    ];
72d4265d07SGreg Roach
7345fc2659SGreg Roach    public const EXTENSION_TO_FORM = [
7445fc2659SGreg Roach        'jpg' => 'jpeg',
7545fc2659SGreg Roach        'tif' => 'tiff',
7645fc2659SGreg Roach    ];
7745fc2659SGreg Roach
78d4265d07SGreg Roach    /**
79d4265d07SGreg Roach     * What is the largest file a user may upload?
80d4265d07SGreg Roach     */
81d4265d07SGreg Roach    public function maxUploadFilesize(): string
82d4265d07SGreg Roach    {
83d501c45dSGreg Roach        $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size'));
84d501c45dSGreg Roach        $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize'));
85d501c45dSGreg Roach
86d501c45dSGreg Roach        $bytes =  min($sizePostMax, $sizeUploadMax);
87d4265d07SGreg Roach        $kb    = intdiv($bytes + 1023, 1024);
88d4265d07SGreg Roach
89d4265d07SGreg Roach        return I18N::translate('%s KB', I18N::number($kb));
90d4265d07SGreg Roach    }
91d4265d07SGreg Roach
92d4265d07SGreg Roach    /**
93d501c45dSGreg Roach     * Returns the given size from an ini value in bytes.
94d501c45dSGreg Roach     *
95284014f8SGreg Roach     * @param string $size
96d501c45dSGreg Roach     *
97d501c45dSGreg Roach     * @return int
98d501c45dSGreg Roach     */
99284014f8SGreg Roach    private function parseIniFileSize(string $size): int
100d501c45dSGreg Roach    {
101d501c45dSGreg Roach        $number = (int) $size;
102d501c45dSGreg Roach
103d501c45dSGreg Roach        switch (substr($size, -1)) {
104d501c45dSGreg Roach            case 'g':
105d501c45dSGreg Roach            case 'G':
106fc26b4f6SGreg Roach                return $number * 1073741824;
107d501c45dSGreg Roach            case 'm':
108d501c45dSGreg Roach            case 'M':
109fc26b4f6SGreg Roach                return $number * 1048576;
110d501c45dSGreg Roach            case 'k':
111d501c45dSGreg Roach            case 'K':
112d501c45dSGreg Roach                return $number * 1024;
113d501c45dSGreg Roach            default:
114d501c45dSGreg Roach                return $number;
115d501c45dSGreg Roach        }
116d501c45dSGreg Roach    }
117d501c45dSGreg Roach
118d501c45dSGreg Roach    /**
119d4265d07SGreg Roach     * A list of key/value options for media types.
120d4265d07SGreg Roach     *
121d4265d07SGreg Roach     * @param string $current
122d4265d07SGreg Roach     *
123bfe98399SGreg Roach     * @return array<string,string>
124d4265d07SGreg Roach     */
125d4265d07SGreg Roach    public function mediaTypes($current = ''): array
126d4265d07SGreg Roach    {
127d4265d07SGreg Roach        $media_types = GedcomTag::getFileFormTypes();
128d4265d07SGreg Roach
129d4265d07SGreg Roach        $media_types = ['' => ''] + [$current => $current] + $media_types;
130d4265d07SGreg Roach
131d4265d07SGreg Roach        return $media_types;
132d4265d07SGreg Roach    }
133d4265d07SGreg Roach
134d4265d07SGreg Roach    /**
135d4265d07SGreg Roach     * A list of media files not already linked to a media object.
136d4265d07SGreg Roach     *
137d4265d07SGreg Roach     * @param Tree                $tree
138a04bb9a2SGreg Roach     * @param FilesystemInterface $data_filesystem
139d4265d07SGreg Roach     *
140bfe98399SGreg Roach     * @return array<string>
141d4265d07SGreg Roach     */
142a04bb9a2SGreg Roach    public function unusedFiles(Tree $tree, FilesystemInterface $data_filesystem): array
143d4265d07SGreg Roach    {
144d4265d07SGreg Roach        $used_files = DB::table('media_file')
145d4265d07SGreg Roach            ->where('m_file', '=', $tree->id())
146d4265d07SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
147d4265d07SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
148d4265d07SGreg Roach            ->pluck('multimedia_file_refn')
149d4265d07SGreg Roach            ->all();
150d4265d07SGreg Roach
151a04bb9a2SGreg Roach        $disk_files = $tree->mediaFilesystem($data_filesystem)->listContents('', true);
152d4265d07SGreg Roach
153d4265d07SGreg Roach        $disk_files = array_filter($disk_files, static function (array $item) {
154d4265d07SGreg Roach            // Older versions of webtrees used a couple of special folders.
155d4265d07SGreg Roach            return
156d4265d07SGreg Roach                $item['type'] === 'file' &&
157d4265d07SGreg Roach                strpos($item['path'], '/thumbs/') === false &&
158d4265d07SGreg Roach                strpos($item['path'], '/watermarks/') === false;
159d4265d07SGreg Roach        });
160d4265d07SGreg Roach
161d4265d07SGreg Roach        $disk_files = array_map(static function (array $item): string {
162d4265d07SGreg Roach            return $item['path'];
163d4265d07SGreg Roach        }, $disk_files);
164d4265d07SGreg Roach
165d4265d07SGreg Roach        $unused_files = array_diff($disk_files, $used_files);
166d4265d07SGreg Roach
167d4265d07SGreg Roach        sort($unused_files);
168d4265d07SGreg Roach
169d4265d07SGreg Roach        return array_combine($unused_files, $unused_files);
170d4265d07SGreg Roach    }
171d4265d07SGreg Roach
172d4265d07SGreg Roach    /**
173d4265d07SGreg Roach     * Store an uploaded file (or URL), either to be added to a media object
174d4265d07SGreg Roach     * or to create a media object.
175d4265d07SGreg Roach     *
176d4265d07SGreg Roach     * @param ServerRequestInterface $request
177d4265d07SGreg Roach     *
178d4265d07SGreg Roach     * @return string The value to be stored in the 'FILE' field of the media object.
179d4265d07SGreg Roach     */
180d4265d07SGreg Roach    public function uploadFile(ServerRequestInterface $request): string
181d4265d07SGreg Roach    {
182d4265d07SGreg Roach        $tree = $request->getAttribute('tree');
183d4265d07SGreg Roach        assert($tree instanceof Tree);
184d4265d07SGreg Roach
185a04bb9a2SGreg Roach        $data_filesystem = $request->getAttribute('filesystem.data');
186a04bb9a2SGreg Roach        assert($data_filesystem instanceof FilesystemInterface);
187a04bb9a2SGreg Roach
188b46c87bdSGreg Roach        $params        = (array) $request->getParsedBody();
189d4265d07SGreg Roach        $file_location = $params['file_location'];
190d4265d07SGreg Roach
191d4265d07SGreg Roach        switch ($file_location) {
192d4265d07SGreg Roach            case 'url':
193d4265d07SGreg Roach                $remote = $params['remote'];
194d4265d07SGreg Roach
195d4265d07SGreg Roach                if (strpos($remote, '://') !== false) {
196d4265d07SGreg Roach                    return $remote;
197d4265d07SGreg Roach                }
198d4265d07SGreg Roach
199d4265d07SGreg Roach                return '';
200d4265d07SGreg Roach
201d4265d07SGreg Roach            case 'unused':
202d4265d07SGreg Roach                $unused = $params['unused'];
203d4265d07SGreg Roach
204a04bb9a2SGreg Roach                if ($tree->mediaFilesystem($data_filesystem)->has($unused)) {
205d4265d07SGreg Roach                    return $unused;
206d4265d07SGreg Roach                }
207d4265d07SGreg Roach
208d4265d07SGreg Roach                return '';
209d4265d07SGreg Roach
210d4265d07SGreg Roach            case 'upload':
211d4265d07SGreg Roach            default:
212d4265d07SGreg Roach                $folder   = $params['folder'];
213d4265d07SGreg Roach                $auto     = $params['auto'];
214d4265d07SGreg Roach                $new_file = $params['new_file'];
215d4265d07SGreg Roach
216d4265d07SGreg Roach                /** @var UploadedFileInterface|null $uploaded_file */
217d4265d07SGreg Roach                $uploaded_file = $request->getUploadedFiles()['file'];
218d4265d07SGreg Roach                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
219d4265d07SGreg Roach                    return '';
220d4265d07SGreg Roach                }
221d4265d07SGreg Roach
222d4265d07SGreg Roach                // The filename
223d4265d07SGreg Roach                $new_file = str_replace('\\', '/', $new_file);
224d4265d07SGreg Roach                if ($new_file !== '' && strpos($new_file, '/') === false) {
225d4265d07SGreg Roach                    $file = $new_file;
226d4265d07SGreg Roach                } else {
227d4265d07SGreg Roach                    $file = $uploaded_file->getClientFilename();
228d4265d07SGreg Roach                }
229d4265d07SGreg Roach
230d4265d07SGreg Roach                // The folder
231d4265d07SGreg Roach                $folder = str_replace('\\', '/', $folder);
232d4265d07SGreg Roach                $folder = trim($folder, '/');
233d4265d07SGreg Roach                if ($folder !== '') {
234d4265d07SGreg Roach                    $folder .= '/';
235d4265d07SGreg Roach                }
236d4265d07SGreg Roach
237d4265d07SGreg Roach                // Generate a unique name for the file?
238a04bb9a2SGreg Roach                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->has($folder . $file)) {
239d4265d07SGreg Roach                    $folder    = '';
240d4265d07SGreg Roach                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
241d4265d07SGreg Roach                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
242d4265d07SGreg Roach                }
243d4265d07SGreg Roach
244d4265d07SGreg Roach                try {
2459ddec9bcSGreg Roach                    $tree->mediaFilesystem($data_filesystem)->putStream($folder . $file, $uploaded_file->getStream()->detach());
246d4265d07SGreg Roach
247d4265d07SGreg Roach                    return $folder . $file;
248d4265d07SGreg Roach                } catch (RuntimeException | InvalidArgumentException $ex) {
249d4265d07SGreg Roach                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
250d4265d07SGreg Roach
251d4265d07SGreg Roach                    return '';
252d4265d07SGreg Roach                }
253d4265d07SGreg Roach        }
254d4265d07SGreg Roach    }
255d4265d07SGreg Roach
256d4265d07SGreg Roach    /**
257d4265d07SGreg Roach     * Convert the media file attributes into GEDCOM format.
258d4265d07SGreg Roach     *
259d4265d07SGreg Roach     * @param string $file
260d4265d07SGreg Roach     * @param string $type
261d4265d07SGreg Roach     * @param string $title
26245fc2659SGreg Roach     * @param string $note
263d4265d07SGreg Roach     *
264d4265d07SGreg Roach     * @return string
265d4265d07SGreg Roach     */
26645fc2659SGreg Roach    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
267d4265d07SGreg Roach    {
268*2b44f6fbSGreg Roach        // Tidy non-printing characters
26945fc2659SGreg Roach        $type  = trim(preg_replace('/\s+/', ' ', $type));
27045fc2659SGreg Roach        $title = trim(preg_replace('/\s+/', ' ', $title));
271d4265d07SGreg Roach
272d4265d07SGreg Roach        $gedcom = '1 FILE ' . $file;
27345fc2659SGreg Roach
27445fc2659SGreg Roach        $format = strtolower(pathinfo($file, PATHINFO_EXTENSION));
27545fc2659SGreg Roach        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
27645fc2659SGreg Roach
27745fc2659SGreg Roach        if ($format !== '') {
27845fc2659SGreg Roach            $gedcom .= "\n2 FORM " . $format;
27945fc2659SGreg Roach        } elseif ($type !== '') {
28045fc2659SGreg Roach            $gedcom .= "\n2 FORM";
281d4265d07SGreg Roach        }
28245fc2659SGreg Roach
28345fc2659SGreg Roach        if ($type !== '') {
28445fc2659SGreg Roach            $gedcom .= "\n3 TYPE " . $type;
28545fc2659SGreg Roach        }
28645fc2659SGreg Roach
287d4265d07SGreg Roach        if ($title !== '') {
288d4265d07SGreg Roach            $gedcom .= "\n2 TITL " . $title;
289d4265d07SGreg Roach        }
290d4265d07SGreg Roach
29145fc2659SGreg Roach        if ($note !== '') {
29245fc2659SGreg Roach            // Convert HTML line endings to GEDCOM continuations
29345fc2659SGreg Roach            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
29445fc2659SGreg Roach        }
29545fc2659SGreg Roach
296d4265d07SGreg Roach        return $gedcom;
297d4265d07SGreg Roach    }
29813aa75d8SGreg Roach
29913aa75d8SGreg Roach    /**
30013aa75d8SGreg Roach     * Fetch a list of all files on disk (in folders used by any tree).
30113aa75d8SGreg Roach     *
30213aa75d8SGreg Roach     * @param FilesystemInterface $data_filesystem Fileystem to search
30313aa75d8SGreg Roach     * @param string              $media_folder    Root folder
30413aa75d8SGreg Roach     * @param bool                $subfolders      Include subfolders
30513aa75d8SGreg Roach     *
306b5c8fd7eSGreg Roach     * @return Collection<string>
30713aa75d8SGreg Roach     */
30813aa75d8SGreg Roach    public function allFilesOnDisk(FilesystemInterface $data_filesystem, string $media_folder, bool $subfolders): Collection
30913aa75d8SGreg Roach    {
31013aa75d8SGreg Roach        $array = $data_filesystem->listContents($media_folder, $subfolders);
31113aa75d8SGreg Roach
31213aa75d8SGreg Roach        return Collection::make($array)
31313aa75d8SGreg Roach            ->filter(static function (array $metadata): bool {
31413aa75d8SGreg Roach                return
31513aa75d8SGreg Roach                    $metadata['type'] === 'file' &&
31613aa75d8SGreg Roach                    strpos($metadata['path'], '/thumbs/') === false &&
31713aa75d8SGreg Roach                    strpos($metadata['path'], '/watermark/') === false;
31813aa75d8SGreg Roach            })
31913aa75d8SGreg Roach            ->map(static function (array $metadata): string {
32013aa75d8SGreg Roach                return $metadata['path'];
32113aa75d8SGreg Roach            });
32213aa75d8SGreg Roach    }
32313aa75d8SGreg Roach
32413aa75d8SGreg Roach    /**
32513aa75d8SGreg Roach     * Fetch a list of all files on in the database.
32613aa75d8SGreg Roach     *
32713aa75d8SGreg Roach     * @param string $media_folder Root folder
32813aa75d8SGreg Roach     * @param bool   $subfolders   Include subfolders
32913aa75d8SGreg Roach     *
330b5c8fd7eSGreg Roach     * @return Collection<string>
33113aa75d8SGreg Roach     */
33213aa75d8SGreg Roach    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
33313aa75d8SGreg Roach    {
33413aa75d8SGreg Roach        $query = DB::table('media_file')
33513aa75d8SGreg Roach            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
33613aa75d8SGreg Roach            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
33713aa75d8SGreg Roach            //->where('multimedia_file_refn', 'LIKE', '%/%')
33813aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
33913aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
34013aa75d8SGreg Roach            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
34113aa75d8SGreg Roach            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
34213aa75d8SGreg Roach            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
34313aa75d8SGreg Roach
34413aa75d8SGreg Roach        if (!$subfolders) {
34513aa75d8SGreg Roach            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
34613aa75d8SGreg Roach        }
34713aa75d8SGreg Roach
34813aa75d8SGreg Roach        return $query->pluck('path');
34913aa75d8SGreg Roach    }
35013aa75d8SGreg Roach
35113aa75d8SGreg Roach    /**
35213aa75d8SGreg Roach     * Generate a list of all folders in either the database or the filesystem.
35313aa75d8SGreg Roach     *
35413aa75d8SGreg Roach     * @param FilesystemInterface $data_filesystem
35513aa75d8SGreg Roach     *
356b5c8fd7eSGreg Roach     * @return Collection<string,string>
35713aa75d8SGreg Roach     */
35813aa75d8SGreg Roach    public function allMediaFolders(FilesystemInterface $data_filesystem): Collection
35913aa75d8SGreg Roach    {
36013aa75d8SGreg Roach        $db_folders = DB::table('media_file')
36113aa75d8SGreg Roach            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
36213aa75d8SGreg Roach            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
36313aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
36413aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
36513aa75d8SGreg Roach            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
36613aa75d8SGreg Roach            ->pluck('path')
36713aa75d8SGreg Roach            ->map(static function (string $path): string {
36813aa75d8SGreg Roach                return dirname($path) . '/';
36913aa75d8SGreg Roach            });
37013aa75d8SGreg Roach
37113aa75d8SGreg Roach        $media_roots = DB::table('gedcom_setting')
37213aa75d8SGreg Roach            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
3735d32b84fSGreg Roach            ->where('gedcom_id', '>', '0')
37413aa75d8SGreg Roach            ->pluck('setting_value')
3758c627a69SGreg Roach            ->uniqueStrict();
37613aa75d8SGreg Roach
37713aa75d8SGreg Roach        $disk_folders = new Collection($media_roots);
37813aa75d8SGreg Roach
37913aa75d8SGreg Roach        foreach ($media_roots as $media_folder) {
38013aa75d8SGreg Roach            $tmp = Collection::make($data_filesystem->listContents($media_folder, true))
38113aa75d8SGreg Roach                ->filter(static function (array $metadata) {
38213aa75d8SGreg Roach                    return $metadata['type'] === 'dir';
38313aa75d8SGreg Roach                })
38413aa75d8SGreg Roach                ->map(static function (array $metadata): string {
38513aa75d8SGreg Roach                    return $metadata['path'] . '/';
38613aa75d8SGreg Roach                })
38713aa75d8SGreg Roach                ->filter(static function (string $dir): bool {
38813aa75d8SGreg Roach                    return strpos($dir, '/thumbs/') === false && strpos($dir, 'watermarks') === false;
38913aa75d8SGreg Roach                });
39013aa75d8SGreg Roach
39113aa75d8SGreg Roach            $disk_folders = $disk_folders->concat($tmp);
39213aa75d8SGreg Roach        }
39313aa75d8SGreg Roach
39413aa75d8SGreg Roach        return $disk_folders->concat($db_folders)
3958c627a69SGreg Roach            ->uniqueStrict()
39613aa75d8SGreg Roach            ->mapWithKeys(static function (string $folder): array {
39713aa75d8SGreg Roach                return [$folder => $folder];
39813aa75d8SGreg Roach            });
39913aa75d8SGreg Roach    }
400d4265d07SGreg Roach}
401