支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
551 行
18 KiB
PHP
551 行
18 KiB
PHP
#!/usr/bin/env php
|
||
<?php
|
||
/**
|
||
* 批量压缩 public/uploads 下的图片(支持 2G+ 大批量)
|
||
*
|
||
* 安全机制:
|
||
* - 压缩结果写入临时文件,校验通过后才覆盖原文件
|
||
* - 配合 --snapshot 先全量备份,可一键恢复
|
||
* - 单张失败不影响其他文件,原图不会被损坏
|
||
*
|
||
* 用法:
|
||
* php deploy/compress-uploads.php --dry-run
|
||
* php deploy/compress-uploads.php --snapshot # 推荐:先快照再压缩
|
||
* bash deploy/compress-uploads.sh --safe # 同上(shell 用 rsync 快照更快)
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
ini_set('memory_limit', '512M');
|
||
set_time_limit(0);
|
||
|
||
const DEFAULT_MAX_WIDTH = 800;
|
||
const DEFAULT_QUALITY = 65;
|
||
const BACKUP_DIR_NAME = '.compress_backup';
|
||
const SNAPSHOTS_DIR = '.compress_backup/snapshots';
|
||
const SUPPORTED_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
|
||
|
||
$options = getopt('', [
|
||
'dry-run',
|
||
'backup',
|
||
'snapshot',
|
||
'no-backup',
|
||
'max-width:',
|
||
'quality:',
|
||
'path:',
|
||
'log:',
|
||
'help',
|
||
]);
|
||
|
||
if (isset($options['help'])) {
|
||
echo <<<HELP
|
||
批量压缩 uploads 图片(安全模式,适合 2G+ 大批量)
|
||
|
||
选项:
|
||
--dry-run 仅预览,不写入
|
||
--snapshot 压缩前先做全量快照(推荐,可一键恢复)
|
||
--backup 每张覆盖前单独备份(与 --snapshot 二选一即可)
|
||
--no-backup 不备份(不推荐,仅测试用)
|
||
--max-width=800 最大宽度
|
||
--quality=65 JPEG/WebP 质量
|
||
--path=DIR 目标目录
|
||
--log=FILE 写入日志文件
|
||
--help 显示帮助
|
||
|
||
推荐流程(2G 数据):
|
||
1. php deploy/compress-uploads.php --dry-run
|
||
2. bash deploy/compress-uploads.sh --safe
|
||
3. 若有异常: bash deploy/restore-uploads.sh --latest
|
||
|
||
HELP;
|
||
exit(0);
|
||
}
|
||
|
||
$dryRun = isset($options['dry-run']);
|
||
$doSnapshot = isset($options['snapshot']);
|
||
$doBackup = isset($options['backup']) && !isset($options['no-backup']);
|
||
$maxWidth = isset($options['max-width']) ? max(1, (int) $options['max-width']) : DEFAULT_MAX_WIDTH;
|
||
$quality = isset($options['quality']) ? min(100, max(1, (int) $options['quality'])) : DEFAULT_QUALITY;
|
||
$logFile = $options['log'] ?? null;
|
||
|
||
$projectRoot = dirname(__DIR__);
|
||
$uploadsDir = isset($options['path'])
|
||
? rtrim($options['path'], DIRECTORY_SEPARATOR)
|
||
: $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads';
|
||
|
||
if (!is_dir($uploadsDir)) {
|
||
logLine("ERROR: 目录不存在: {$uploadsDir}", $logFile);
|
||
exit(1);
|
||
}
|
||
|
||
if (!extension_loaded('gd')) {
|
||
logLine('ERROR: 需要 PHP GD 扩展', $logFile);
|
||
exit(1);
|
||
}
|
||
|
||
$backupRoot = $uploadsDir . DIRECTORY_SEPARATOR . BACKUP_DIR_NAME;
|
||
$snapshotRoot = $uploadsDir . DIRECTORY_SEPARATOR . SNAPSHOTS_DIR;
|
||
|
||
// ---------- 预检 ----------
|
||
$preflight = scanUploads($uploadsDir);
|
||
logLine("========== 预检 ==========", $logFile);
|
||
logLine("目标目录: {$uploadsDir}", $logFile);
|
||
logLine("图片数量: {$preflight['count']} 总大小: " . formatBytes($preflight['total_bytes']), $logFile);
|
||
logLine("磁盘可用: " . formatBytes(diskFreeBytes($uploadsDir)), $logFile);
|
||
logLine("最大宽度: {$maxWidth}px | 质量: {$quality}%", $logFile);
|
||
|
||
if (!$dryRun && !$doSnapshot && !$doBackup) {
|
||
logLine('WARN: 未启用 --snapshot 或 --backup,压缩失败时无法恢复。建议加 --snapshot', $logFile);
|
||
}
|
||
|
||
if (!$dryRun && $doSnapshot) {
|
||
$needSpace = $preflight['total_bytes'] * 1.1; // 快照 + 10% 缓冲
|
||
$freeSpace = diskFreeBytes($uploadsDir);
|
||
if ($freeSpace > 0 && $freeSpace < $needSpace) {
|
||
logLine('ERROR: 磁盘空间不足。需要约 ' . formatBytes((int) $needSpace)
|
||
. ',可用 ' . formatBytes($freeSpace), $logFile);
|
||
logLine('请清理磁盘或使用 bash deploy/compress-uploads.sh --safe(rsync 快照到其他盘)', $logFile);
|
||
exit(1);
|
||
}
|
||
}
|
||
|
||
// ---------- 全量快照 ----------
|
||
$snapshotId = null;
|
||
if ($doSnapshot && !$dryRun) {
|
||
$snapshotId = date('Ymd_His');
|
||
$snapshotDir = $snapshotRoot . DIRECTORY_SEPARATOR . $snapshotId;
|
||
logLine("\n========== 创建全量快照: {$snapshotId} ==========", $logFile);
|
||
logLine("快照目录: {$snapshotDir}", $logFile);
|
||
logLine('(约 2G 需数分钟,请耐心等待)', $logFile);
|
||
|
||
$snapResult = createSnapshot($uploadsDir, $snapshotDir, $logFile);
|
||
if (!$snapResult['ok']) {
|
||
logLine('ERROR: 快照失败 - ' . $snapResult['reason'], $logFile);
|
||
exit(1);
|
||
}
|
||
logLine("快照完成: {$snapResult['files']} 个文件, " . formatBytes($snapResult['bytes']), $logFile);
|
||
writeSnapshotMeta($snapshotDir, $preflight, $maxWidth, $quality);
|
||
logLine("恢复命令: bash deploy/restore-uploads.sh --restore={$snapshotId}", $logFile);
|
||
}
|
||
|
||
// ---------- 压缩 ----------
|
||
$stats = ['scanned' => 0, 'processed' => 0, 'skipped' => 0, 'saved_bytes' => 0, 'errors' => 0];
|
||
$startTime = microtime(true);
|
||
|
||
logLine("\n========== 开始压缩" . ($dryRun ? ' [dry-run]' : '') . " ==========", $logFile);
|
||
|
||
foreach ($preflight['files'] as $item) {
|
||
$path = $item['path'];
|
||
$relative = $item['relative'];
|
||
$ext = $item['ext'];
|
||
|
||
$stats['scanned']++;
|
||
$result = processImage($path, $ext, $maxWidth, $quality, $dryRun, $doBackup, $uploadsDir, $backupRoot);
|
||
|
||
switch ($result['status']) {
|
||
case 'ok':
|
||
$stats['processed']++;
|
||
$stats['saved_bytes'] += $result['saved'];
|
||
$savedKb = round($result['saved'] / 1024, 1);
|
||
logLine("[OK] {$relative} {$result['before_w']}x{$result['before_h']}"
|
||
. " -> {$result['after_w']}x{$result['after_h']}"
|
||
. ' ' . formatBytes($result['before_size']) . ' -> ' . formatBytes($result['after_size'])
|
||
. " (-{$savedKb}KB)", $logFile);
|
||
break;
|
||
case 'skip':
|
||
$stats['skipped']++;
|
||
break;
|
||
case 'error':
|
||
$stats['errors']++;
|
||
logLine("[ERR] {$relative} {$result['reason']}", $logFile);
|
||
break;
|
||
}
|
||
|
||
if ($stats['scanned'] % 100 === 0) {
|
||
$elapsed = round(microtime(true) - $startTime);
|
||
logLine("--- 进度 {$stats['scanned']}/{$preflight['count']} | 已用 {$elapsed}s ---", $logFile);
|
||
}
|
||
}
|
||
|
||
$elapsed = round(microtime(true) - $startTime);
|
||
logLine("\n========== 完成 (耗时 {$elapsed}s) ==========", $logFile);
|
||
logLine("扫描: {$stats['scanned']} | 压缩: {$stats['processed']} | 跳过: {$stats['skipped']} | 失败: {$stats['errors']}", $logFile);
|
||
logLine('共节省: ' . formatBytes($stats['saved_bytes']), $logFile);
|
||
|
||
if ($snapshotId) {
|
||
logLine("\n若需恢复全部原图:", $logFile);
|
||
logLine(" bash deploy/restore-uploads.sh --restore={$snapshotId}", $logFile);
|
||
}
|
||
|
||
exit($stats['errors'] > 0 ? 1 : 0);
|
||
|
||
// ======================== 核心逻辑 ========================
|
||
|
||
/**
|
||
* @return array{status:string,reason?:string,before_w?:int,before_h?:int,after_w?:int,after_h?:int,before_size?:int,after_size?:int,saved?:int}
|
||
*/
|
||
function processImage(
|
||
string $path,
|
||
string $ext,
|
||
int $maxWidth,
|
||
int $quality,
|
||
bool $dryRun,
|
||
bool $doBackup,
|
||
string $uploadsDir,
|
||
string $backupRoot
|
||
): array {
|
||
$beforeSize = filesize($path);
|
||
if ($beforeSize === false || $beforeSize === 0) {
|
||
return ['status' => 'error', 'reason' => '无法读取文件'];
|
||
}
|
||
|
||
$info = @getimagesize($path);
|
||
if (!$info || empty($info[0]) || empty($info[1])) {
|
||
return ['status' => 'skip', 'reason' => '非有效图片'];
|
||
}
|
||
|
||
[$beforeW, $beforeH] = [$info[0], $info[1]];
|
||
|
||
if ($ext === 'gif' && isAnimatedGif($path)) {
|
||
return ['status' => 'skip', 'reason' => '动画 GIF'];
|
||
}
|
||
|
||
$image = loadImage($path, $ext);
|
||
if ($image === null) {
|
||
return ['status' => 'error', 'reason' => 'GD 无法加载'];
|
||
}
|
||
|
||
if (in_array($ext, ['jpg', 'jpeg'], true)) {
|
||
$image = applyExifOrientation($image, $path);
|
||
$beforeW = imagesx($image);
|
||
$beforeH = imagesy($image);
|
||
}
|
||
|
||
$afterW = $beforeW;
|
||
$afterH = $beforeH;
|
||
$needResize = $beforeW > $maxWidth;
|
||
|
||
if ($needResize) {
|
||
$afterW = $maxWidth;
|
||
$afterH = max(1, (int) round($beforeH * ($maxWidth / $beforeW)));
|
||
$resized = imagecreatetruecolor($afterW, $afterH);
|
||
if ($resized === false) {
|
||
imagedestroy($image);
|
||
return ['status' => 'error', 'reason' => '创建画布失败'];
|
||
}
|
||
preserveTransparency($resized, $ext);
|
||
imagecopyresampled($resized, $image, 0, 0, 0, 0, $afterW, $afterH, $beforeW, $beforeH);
|
||
imagedestroy($image);
|
||
$image = $resized;
|
||
}
|
||
|
||
$tmpFile = $path . '.compress_tmp.' . getmypid() . '.' . mt_rand(1000, 9999);
|
||
if (!saveImage($image, $tmpFile, $ext, $quality)) {
|
||
imagedestroy($image);
|
||
@unlink($tmpFile);
|
||
return ['status' => 'error', 'reason' => '保存临时文件失败'];
|
||
}
|
||
imagedestroy($image);
|
||
|
||
$validate = validateCompressedFile($tmpFile, $ext, $afterW, $afterH);
|
||
if (!$validate['ok']) {
|
||
@unlink($tmpFile);
|
||
return ['status' => 'error', 'reason' => '校验失败: ' . $validate['reason']];
|
||
}
|
||
|
||
$afterSize = filesize($tmpFile);
|
||
if ($afterSize === false || $afterSize === 0) {
|
||
@unlink($tmpFile);
|
||
return ['status' => 'error', 'reason' => '临时文件为空'];
|
||
}
|
||
|
||
if ($afterSize >= $beforeSize && !$needResize) {
|
||
@unlink($tmpFile);
|
||
return ['status' => 'skip', 'reason' => '已足够小'];
|
||
}
|
||
|
||
if ($dryRun) {
|
||
@unlink($tmpFile);
|
||
return buildOkResult($beforeW, $beforeH, $afterW, $afterH, $beforeSize, $afterSize);
|
||
}
|
||
|
||
// 先备份原文件,再原子替换
|
||
if ($doBackup) {
|
||
$relative = ltrim(str_replace($uploadsDir, '', $path), DIRECTORY_SEPARATOR);
|
||
$backupPath = $backupRoot . DIRECTORY_SEPARATOR . 'per_file' . DIRECTORY_SEPARATOR . $relative;
|
||
if (!backupFile($path, $backupPath)) {
|
||
@unlink($tmpFile);
|
||
return ['status' => 'error', 'reason' => '单文件备份失败'];
|
||
}
|
||
}
|
||
|
||
if (!@rename($tmpFile, $path)) {
|
||
@unlink($tmpFile);
|
||
return ['status' => 'error', 'reason' => '覆盖原文件失败'];
|
||
}
|
||
@chmod($path, 0644);
|
||
|
||
// 替换后再验一次,异常则从 per_file 备份恢复
|
||
$postCheck = @getimagesize($path);
|
||
if (!$postCheck) {
|
||
if ($doBackup && isset($backupPath) && file_exists($backupPath)) {
|
||
copy($backupPath, $path);
|
||
}
|
||
return ['status' => 'error', 'reason' => '替换后校验失败,已回滚单文件'];
|
||
}
|
||
|
||
return buildOkResult($beforeW, $beforeH, $afterW, $afterH, $beforeSize, $afterSize);
|
||
}
|
||
|
||
function validateCompressedFile(string $path, string $ext, int $expectW, int $expectH): array
|
||
{
|
||
$info = @getimagesize($path);
|
||
if (!$info || empty($info[0]) || empty($info[1])) {
|
||
return ['ok' => false, 'reason' => 'getimagesize 失败'];
|
||
}
|
||
|
||
if ((int) $info[0] !== $expectW || (int) $info[1] !== $expectH) {
|
||
return ['ok' => false, 'reason' => "尺寸不符 {$info[0]}x{$info[1]} != {$expectW}x{$expectH}"];
|
||
}
|
||
|
||
$img = loadImage($path, $ext);
|
||
if ($img === null) {
|
||
return ['ok' => false, 'reason' => 'GD 无法重新加载'];
|
||
}
|
||
imagedestroy($img);
|
||
|
||
return ['ok' => true];
|
||
}
|
||
|
||
function createSnapshot(string $uploadsDir, string $snapshotDir, ?string $logFile): array
|
||
{
|
||
if (!is_dir($snapshotDir) && !mkdir($snapshotDir, 0755, true) && !is_dir($snapshotDir)) {
|
||
return ['ok' => false, 'reason' => '无法创建快照目录'];
|
||
}
|
||
|
||
$files = 0;
|
||
$bytes = 0;
|
||
$iterator = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS)
|
||
);
|
||
|
||
foreach ($iterator as $fileInfo) {
|
||
/** @var SplFileInfo $fileInfo */
|
||
if (!$fileInfo->isFile()) {
|
||
continue;
|
||
}
|
||
$src = $fileInfo->getPathname();
|
||
$relative = ltrim(str_replace($uploadsDir, '', $src), DIRECTORY_SEPARATOR);
|
||
|
||
if (strpos($relative, BACKUP_DIR_NAME) === 0) {
|
||
continue;
|
||
}
|
||
|
||
$dst = $snapshotDir . DIRECTORY_SEPARATOR . $relative;
|
||
$dstDir = dirname($dst);
|
||
if (!is_dir($dstDir) && !mkdir($dstDir, 0755, true) && !is_dir($dstDir)) {
|
||
return ['ok' => false, 'reason' => "无法创建 {$dstDir}"];
|
||
}
|
||
|
||
if (!copy($src, $dst)) {
|
||
return ['ok' => false, 'reason' => "复制失败: {$relative}"];
|
||
}
|
||
|
||
$size = filesize($src);
|
||
$files++;
|
||
$bytes += $size ?: 0;
|
||
|
||
if ($files % 200 === 0) {
|
||
logLine(" 快照进度: {$files} 文件, " . formatBytes($bytes), $logFile);
|
||
}
|
||
}
|
||
|
||
return ['ok' => true, 'files' => $files, 'bytes' => $bytes];
|
||
}
|
||
|
||
function writeSnapshotMeta(string $snapshotDir, array $preflight, int $maxWidth, int $quality): void
|
||
{
|
||
$meta = [
|
||
'created_at' => date('c'),
|
||
'image_count' => $preflight['count'],
|
||
'total_bytes' => $preflight['total_bytes'],
|
||
'max_width' => $maxWidth,
|
||
'quality' => $quality,
|
||
];
|
||
file_put_contents($snapshotDir . DIRECTORY_SEPARATOR . '.snapshot_meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
function scanUploads(string $uploadsDir): array
|
||
{
|
||
$files = [];
|
||
$totalBytes = 0;
|
||
|
||
$iterator = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS)
|
||
);
|
||
|
||
foreach ($iterator as $fileInfo) {
|
||
/** @var SplFileInfo $fileInfo */
|
||
if (!$fileInfo->isFile()) {
|
||
continue;
|
||
}
|
||
$path = $fileInfo->getPathname();
|
||
$relative = ltrim(str_replace($uploadsDir, '', $path), DIRECTORY_SEPARATOR);
|
||
|
||
if (strpos($relative, BACKUP_DIR_NAME) === 0) {
|
||
continue;
|
||
}
|
||
|
||
$ext = strtolower($fileInfo->getExtension());
|
||
if (!in_array($ext, SUPPORTED_EXT, true)) {
|
||
continue;
|
||
}
|
||
|
||
$size = $fileInfo->getSize();
|
||
$files[] = ['path' => $path, 'relative' => $relative, 'ext' => $ext, 'size' => $size];
|
||
$totalBytes += $size;
|
||
}
|
||
|
||
return ['files' => $files, 'count' => count($files), 'total_bytes' => $totalBytes];
|
||
}
|
||
|
||
function backupFile(string $src, string $dst): bool
|
||
{
|
||
if (file_exists($dst)) {
|
||
return true;
|
||
}
|
||
$dir = dirname($dst);
|
||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||
return false;
|
||
}
|
||
return copy($src, $dst);
|
||
}
|
||
|
||
function buildOkResult(int $beforeW, int $beforeH, int $afterW, int $afterH, int $beforeSize, int $afterSize): array
|
||
{
|
||
return [
|
||
'status' => 'ok',
|
||
'before_w' => $beforeW,
|
||
'before_h' => $beforeH,
|
||
'after_w' => $afterW,
|
||
'after_h' => $afterH,
|
||
'before_size' => $beforeSize,
|
||
'after_size' => $afterSize,
|
||
'saved' => max(0, $beforeSize - $afterSize),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return resource|\GdImage|null
|
||
*/
|
||
function loadImage(string $path, string $ext)
|
||
{
|
||
switch ($ext) {
|
||
case 'jpg':
|
||
case 'jpeg':
|
||
return @imagecreatefromjpeg($path) ?: null;
|
||
case 'png':
|
||
return @imagecreatefrompng($path) ?: null;
|
||
case 'webp':
|
||
return function_exists('imagecreatefromwebp') ? (@imagecreatefromwebp($path) ?: null) : null;
|
||
case 'gif':
|
||
return @imagecreatefromgif($path) ?: null;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param resource|\GdImage $image
|
||
*/
|
||
function saveImage($image, string $path, string $ext, int $quality): bool
|
||
{
|
||
switch ($ext) {
|
||
case 'jpg':
|
||
case 'jpeg':
|
||
return imagejpeg($image, $path, $quality);
|
||
case 'png':
|
||
$pngLevel = max(0, min(9, (int) round((100 - $quality) / 100 * 9)));
|
||
return imagepng($image, $path, $pngLevel);
|
||
case 'webp':
|
||
return function_exists('imagewebp') ? imagewebp($image, $path, $quality) : false;
|
||
case 'gif':
|
||
return imagegif($image, $path);
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param resource|\GdImage $image
|
||
* @return resource|\GdImage
|
||
*/
|
||
function applyExifOrientation($image, string $path)
|
||
{
|
||
if (!function_exists('exif_read_data')) {
|
||
return $image;
|
||
}
|
||
$exif = @exif_read_data($path);
|
||
if (!$exif || empty($exif['Orientation'])) {
|
||
return $image;
|
||
}
|
||
switch ((int) $exif['Orientation']) {
|
||
case 2: imageflip($image, IMG_FLIP_HORIZONTAL); break;
|
||
case 3: $image = imagerotate($image, 180, 0) ?: $image; break;
|
||
case 4: imageflip($image, IMG_FLIP_VERTICAL); break;
|
||
case 5: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, -90, 0) ?: $image; break;
|
||
case 6: $image = imagerotate($image, -90, 0) ?: $image; break;
|
||
case 7: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, 90, 0) ?: $image; break;
|
||
case 8: $image = imagerotate($image, 90, 0) ?: $image; break;
|
||
}
|
||
return $image;
|
||
}
|
||
|
||
/**
|
||
* @param resource|\GdImage $image
|
||
*/
|
||
function preserveTransparency($image, string $ext): void
|
||
{
|
||
if (!in_array($ext, ['png', 'gif', 'webp'], true)) {
|
||
return;
|
||
}
|
||
imagealphablending($image, false);
|
||
imagesavealpha($image, true);
|
||
$transparent = imagecolorallocatealpha($image, 0, 0, 0, 127);
|
||
imagefilledrectangle($image, 0, 0, imagesx($image), imagesy($image), $transparent);
|
||
}
|
||
|
||
function isAnimatedGif(string $path): bool
|
||
{
|
||
$content = @file_get_contents($path, false, null, 0, 65536);
|
||
return $content !== false && preg_match('/\x00\x21\xF9\x04.{4}\x00[\x2C\x21]/s', $content) === 1;
|
||
}
|
||
|
||
function diskFreeBytes(string $path): int
|
||
{
|
||
$free = @disk_free_space($path);
|
||
return $free !== false ? (int) $free : 0;
|
||
}
|
||
|
||
function formatBytes(int $bytes): string
|
||
{
|
||
if ($bytes >= 1073741824) {
|
||
return round($bytes / 1073741824, 2) . 'GB';
|
||
}
|
||
if ($bytes >= 1048576) {
|
||
return round($bytes / 1048576, 2) . 'MB';
|
||
}
|
||
if ($bytes >= 1024) {
|
||
return round($bytes / 1024, 1) . 'KB';
|
||
}
|
||
return $bytes . 'B';
|
||
}
|
||
|
||
function logLine(string $msg, ?string $logFile): void
|
||
{
|
||
echo $msg . PHP_EOL;
|
||
if ($logFile) {
|
||
file_put_contents($logFile, $msg . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||
}
|
||
}
|