This commit is contained in:
2026-01-01 21:10:46 +08:00
parent 5c1b19c08a
commit 6c31ffa120
26 changed files with 1171 additions and 209 deletions

View File

@@ -12,7 +12,7 @@ namespace App\Controllers;
use App\Core\Bot;
use App\Core\Kernel;
use App\Core\StatisticsService;
use App\Errors\PlaylistNotFoundException;
use App\Exceptions\PlaylistNotFoundException;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Exception;
@@ -38,7 +38,7 @@ class ApiController extends BasicController
$code = $request->getAttributes()['code'] ?? null;
empty($code) && throw new PlaylistNotFoundException('');
$playlist = ini()->getPlaylist($code);
$playlist = ini()->playlist($code);
if ($playlist['isOnline'] === true) {
unset($playlist['content']);
}

View File

@@ -9,7 +9,7 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Errors\PlaylistNotFoundException;
use App\Exceptions\PlaylistNotFoundException;
use Exception;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -36,7 +36,7 @@ class WebController extends BasicController
*/
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$ini = ini()->load();
$ini = ini();
$keys = [];
$count = count($ini);
$pageSize = config('app.page_size');
@@ -45,11 +45,11 @@ class WebController extends BasicController
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
$pageCount = ceil($count / $pageSize);
$offset = max(0, ($pageCurrent - 1) * $pageSize);
$ini = array_slice($ini, $offset, $pageSize, true);
$ini = array_slice($ini->get, $offset, $pageSize, true);
$keys = array_keys($ini);
}
$playlists = ini()->getPlaylists($keys);
$playlists = ini()->playlists($keys);
return $this->view($request, $response, 'list.twig', [
'updatedAt' => ini()->updatedAt(),
@@ -75,7 +75,7 @@ class WebController extends BasicController
$code = $request->getAttributes()['code'];
try {
$playlist = ini()->getPlaylist($code);
$playlist = ini()->playlist($code);
return $response->withHeader('Location', $playlist['url']);
} catch (Throwable) {
return $this->notFound($request, $response);
@@ -98,7 +98,7 @@ class WebController extends BasicController
$code = $request->getAttributes()['code'];
try {
$playlist = ini()->getPlaylist($code);
$playlist = ini()->playlist($code);
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
} catch (PlaylistNotFoundException) {
return $this->notFound($request, $response);

View File

@@ -9,8 +9,8 @@ declare(strict_types=1);
namespace App\Core;
use App\Errors\InvalidTelegramSecretException;
use App\Errors\PlaylistNotFoundException;
use App\Exceptions\InvalidTelegramSecretException;
use App\Exceptions\PlaylistNotFoundException;
use DateTimeImmutable;
use Exception;
use JsonException;
@@ -168,7 +168,7 @@ class Bot
}
try {
$pls = ini()->getPlaylist($code);
$pls = ini()->playlist($code);
} catch (PlaylistNotFoundException) {
return $this->reply("Плейлист `$code` не найден");
}

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
@@ -9,70 +10,71 @@ declare(strict_types=1);
namespace App\Core;
use App\Errors\PlaylistNotFoundException;
use Exception;
use App\Exceptions\FileReadException;
use App\Exceptions\IniParsingException;
use App\Exceptions\PlaylistNotFoundException;
use ArrayAccess;
use Override;
use Throwable;
/**
* Класс для работы со списком плейлистов
*
* @phpstan-type TIniFile array{}|TPlaylistDefinition[]
* @phpstan-type TPlaylistDefinition array{name?: string, desc?: string, url: string, src?: string}
* @template TKey as non-falsy-string
* @template TValue as TPlaylistDefinition
* @implements ArrayAccess<TKey, TValue>
*/
class IniFile
class IniFile implements ArrayAccess
{
/**
* @var array[] Коллекция подгруженных плейлистов
* @var array{}|array<TKey, TValue> Коллекция подгруженных плейлистов
*/
protected array $playlists;
/**
* @var string Дата последнего обновления списка
* @var positive-int Дата последнего обновления списка
*/
protected string $updatedAt;
protected int $updatedAt;
/**
* Считывает ini-файл и инициализирует плейлисты
* Загружает ini-файл и инициализирует плейлисты
*
* @return array
* @param string $filepath
* @throws FileReadException
* @throws IniParsingException
*/
public function load(): array
{
$filepath = config_path('playlists.ini');
$this->playlists = parse_ini_file($filepath, true);
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
return $this->playlists;
}
/**
* Возвращает плейлисты
*
* @return array[]
* @throws Exception
*/
public function getPlaylists(array $plsCodes = []): array
{
$playlists = [];
empty($this->playlists) && $this->load();
empty($plsCodes) && $plsCodes = array_keys($this->playlists);
$cached = array_combine($plsCodes, redis()->mget($plsCodes));
foreach ($cached as $code => $data) {
$playlists[$code] = $this->initPlaylist($code, $data);
public function __construct(
protected string $filepath,
) {
try {
$content = file_get_contents($this->filepath);
} catch (Throwable) {
$content = false;
}
return $playlists;
$content === false && throw new FileReadException($this->filepath);
$parsed = parse_ini_string($content, true);
$parsed === false && throw new IniParsingException($this->filepath);
$this->playlists = $parsed;
/** @var positive-int $timestamp */
$timestamp = is_readable($this->filepath) ? filemtime($this->filepath) : time();
$this->updatedAt = $timestamp;
}
/**
* Возвращает плейлист по его коду
* Возвращает определение плейлиста по его коду
*
* @param string $code Код плейлиста
* @return array|null
* @param TKey $code
* @return TValue
* @throws PlaylistNotFoundException
* @throws Exception
*/
public function getPlaylist(string $code): ?array
public function playlist(string $code): array
{
empty($this->playlists) && $this->load();
$data = redis()->get($code);
return $this->initPlaylist($code, $data);
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
}
/**
@@ -82,107 +84,50 @@ class IniFile
*/
public function updatedAt(): string
{
return $this->updatedAt;
return date('d.m.Y h:i', $this->updatedAt);
}
/**
* Подготавливает данные о плейлисте в расширенном формате
*
* @param string $code
* @param array|false $data
* @return array
* @throws PlaylistNotFoundException
*/
protected function initPlaylist(string $code, array|false $data): array
{
if ($data === false) {
$raw = $this->playlists[$code]
?? throw new PlaylistNotFoundException($code);
$data = [
'code' => $code,
'name' => $raw['name'] ?? "Плейлист #$code",
'description' => $raw['desc'] ?? null,
'url' => $raw['pls'],
'source' => $raw['src'] ?? null,
'content' => null,
'isOnline' => null,
'attributes' => [],
'groups' => [],
'channels' => [],
'checkedAt' => null,
];
}
// приколы golang
$data['attributes'] === null && $data['attributes'] = [];
$data['groups'] === null && $data['groups'] = [];
$data['channels'] === null && $data['channels'] = [];
$data['onlinePercent'] = 0;
$data['offlinePercent'] = 0;
if ($data['isOnline'] === true && count($data['channels']) > 0) {
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
}
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
$data['hasTokens'] = $this->hasTokens($data);
$data['tags'] = [];
foreach ($data['channels'] as &$channel) {
$data['tags'] = array_merge($data['tags'], $channel['tags']);
$channel['hasToken'] = $this->hasTokens($channel);
}
$data['tags'] = array_values(array_unique($data['tags']));
sort($data['tags']);
return $data;
}
/**
* Проверяет наличие токенов в плейлисте
*
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
* в любой момент.
*
* @param array $data
* @inheritDoc
* @param non-falsy-string $offset
* @return bool
*/
protected function hasTokens(array $data): bool
#[Override]
public function offsetExists(mixed $offset): bool
{
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
if (empty($string)) {
return false;
}
return isset($this->playlists[$offset]);
}
$badAttributes = [
// токены и ключи
'[?&]token=',
'[?&]drmreq=',
// логины
'[?&]u=',
'[?&]user=',
'[?&]username=',
// пароли
'[?&]p=',
'[?&]pwd=',
'[?&]password=',
// неизвестные
// 'free=true',
// 'uid=',
// 'c_uniq_tag=',
// 'rlkey=',
// '?s=',
// '&s=',
// '?q=',
// '&q=',
];
/**
* @inheritDoc
* @param TKey $offset
* @return TPlaylistDefinition
* @throws PlaylistNotFoundException
*/
#[Override]
public function offsetGet(mixed $offset): array
{
return $this->playlist($offset);
}
return array_any(
$badAttributes,
static fn (string $badAttribute) => preg_match_all("/$badAttribute/", $string) >= 1,
);
/**
* @inheritDoc
* @param TKey $offset
* @param TValue $value
* @return void
*/
#[Override]
public function offsetSet(mixed $offset, mixed $value): void
{
}
/**
* @inheritDoc
* @param non-falsy-string $offset
* @return void
*/
#[Override]
public function offsetUnset(mixed $offset): void
{
}
}

View File

@@ -222,6 +222,6 @@ final class Kernel
*/
public function ini(): IniFile
{
return $this->iniFile ??= new IniFile();
return $this->iniFile ??= new IniFile(config_path('playlists.ini'));
}
}

216
app/Core/Playlist.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Core;
use App\Exceptions\PlaylistNotFoundException;
use App\Exceptions\PlaylistWithoutUrlException;
/**
* @phpstan-import-type TPlaylistDefinition from IniFile
*/
class Playlist
{
/**
* @var non-falsy-string
*/
public readonly string $code;
/**
* @var non-falsy-string|null
*/
public readonly ?string $desc;
/**
* @var non-falsy-string|null
*/
public readonly ?string $name;
/**
* @var non-falsy-string
*/
public readonly string $url;
/**
* @var non-falsy-string|null
*/
public readonly ?string $src;
protected string $text;
/**
* @param non-falsy-string $code
* @param TPlaylistDefinition $params
*/
public function __construct(string $code, array $params)
{
$this->code = $code;
$this->name = isset($params['name']) ? trim($params['name']) : null;
$this->desc = isset($params['desc']) ? trim($params['desc']) : null;
$this->url = isset($params['url']) ? trim($params['url']) : throw new PlaylistWithoutUrlException($code);
$this->src = isset($params['src']) ? trim($params['src']) : null;
}
public function getCheckResult(\Redis $redis)
{
$this->text = $redis->get($this->code);
$stop = 1;
}
private array $definition;
private array $cached;
/**
* Возвращает плейлисты
*
* @return array[]
* @throws Exception
*/
// public function getCachedPlaylists(array $plsCodes = []): array
// {
// $playlists = [];
// empty($plsCodes) && $plsCodes = array_keys($this->playlists);
// $cached = array_combine($plsCodes, redis()->mget($plsCodes));
// foreach ($cached as $code => $data) {
// $playlists[$code] = $this->initPlaylist($code, $data);
// }
//
// return $playlists;
// }
/**
* Возвращает плейлист по его коду
*
* @param string $code Код плейлиста
* @return array|null
* @throws PlaylistNotFoundException
* @throws Exception
*/
// public function getCachedPlaylist(string $code): ?array
// {
// $data = redis()->get($code);
//
// return $this->initPlaylist($code, $data);
// }
/**
* Подготавливает данные о плейлисте в расширенном формате
*
* @param string $code
* @param array|false $data
* @return array
* @throws PlaylistNotFoundException
*/
protected function initPlaylist(string $code, array|false $data): array
{
if ($data === false) {
$raw = $this->playlists[$code]
?? throw new PlaylistNotFoundException($code);
$data = [
'code' => $code,
'name' => $raw['name'] ?? "Плейлист #{$code}",
'description' => $raw['desc'] ?? null,
'url' => $raw['url'],
'source' => $raw['src'] ?? null,
'content' => null,
'isOnline' => null,
'attributes' => [],
'groups' => [],
'channels' => [],
'checkedAt' => null,
];
}
// приколы golang
$data['attributes'] === null && $data['attributes'] = [];
$data['groups'] === null && $data['groups'] = [];
$data['channels'] === null && $data['channels'] = [];
$data['onlinePercent'] = 0;
$data['offlinePercent'] = 0;
if ($data['isOnline'] === true && count($data['channels']) > 0) {
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
}
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
$data['hasTokens'] = $this->hasTokens($data);
$data['tags'] = [];
foreach ($data['channels'] as &$channel) {
$data['tags'] = array_merge($data['tags'], $channel['tags']);
$channel['hasToken'] = $this->hasTokens($channel);
}
$data['tags'] = array_values(array_unique($data['tags']));
sort($data['tags']);
return $data;
}
/**
* Проверяет наличие токенов в плейлисте
*
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
* в любой момент.
*
* @param array $data
* @return bool
*/
protected function hasTokens(array $data): bool
{
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
if (empty($string)) {
return false;
}
$badAttributes = [
// токены и ключи
'[?&]token=',
'[?&]drmreq=',
// логины
'[?&]u=',
'[?&]user=',
'[?&]username=',
// пароли
'[?&]p=',
'[?&]pwd=',
'[?&]password=',
// неизвестные
// 'free=true',
// 'uid=',
// 'c_uniq_tag=',
// 'rlkey=',
// '?s=',
// '&s=',
// '?q=',
// '&q=',
];
return array_any(
$badAttributes,
static fn (string $badAttribute) => preg_match_all("/{$badAttribute}/", $string) >= 1,
);
}
}

View File

@@ -7,7 +7,7 @@
declare(strict_types=1);
namespace App\Errors;
namespace App\Exceptions;
use Psr\Http\Message\{
ResponseInterface,
@@ -19,7 +19,7 @@ use Throwable;
/**
* Обработчик ошибок
*/
class ErrorHandler extends SlimErrorHandler
class ExceptionHandler extends SlimErrorHandler
{
/**
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым

View File

@@ -0,0 +1,20 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class FileNotFoundException extends Exception
{
public function __construct(string $filepath)
{
parent::__construct('Файл не найден: ' . $filepath);
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class FileReadException extends Exception
{
public function __construct(string $filepath)
{
parent::__construct('Ошибка чтения файла: ' . $filepath);
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class IniParsingException extends Exception
{
public function __construct(string $filepath)
{
parent::__construct('Ошибка разбора файла: ' . $filepath);
}
}

View File

@@ -7,7 +7,7 @@
declare(strict_types=1);
namespace App\Errors;
namespace App\Exceptions;
use Exception;

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
@@ -7,7 +8,7 @@
declare(strict_types=1);
namespace App\Errors;
namespace App\Exceptions;
use Exception;
@@ -15,6 +16,6 @@ class PlaylistNotFoundException extends Exception
{
public function __construct(string $code)
{
parent::__construct("Плейлист '$code' не найден");
parent::__construct("Плейлист '{$code}' не найден");
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Exceptions;
use InvalidArgumentException;
class PlaylistWithoutUrlException extends InvalidArgumentException
{
public function __construct(string $code)
{
parent::__construct("Плейлист '{$code}' имеет неверный url");
}
}

View File

@@ -1,4 +1,5 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
@@ -17,8 +18,8 @@ if (!function_exists('root_path')) {
/**
* Возвращает абсолютный путь к корневой директории приложения.
*
* @param string $path Относительный путь для добавления к корневому.
* @return string Абсолютный путь.
* @param string $path относительный путь для добавления к корневому
* @return string абсолютный путь
*/
function root_path(string $path = ''): string
{
@@ -32,12 +33,12 @@ if (!function_exists('config_path')) {
/**
* Возвращает абсолютный путь к директории конфигурации приложения.
*
* @param string $path Относительный путь для добавления к директории конфигурации.
* @return string Абсолютный путь.
* @param string $path относительный путь для добавления к директории конфигурации
* @return string абсолютный путь
*/
function config_path(string $path = ''): string
{
return root_path("config/$path");
return root_path("config/{$path}");
}
}
@@ -45,12 +46,12 @@ if (!function_exists('cache_path')) {
/**
* Возвращает абсолютный путь к директории кэша приложения.
*
* @param string $path Относительный путь для добавления к директории кэша.
* @return string Абсолютный путь.
* @param string $path относительный путь для добавления к директории кэша
* @return string абсолютный путь
*/
function cache_path(string $path = ''): string
{
return root_path("cache/$path");
return root_path("cache/{$path}");
}
}
@@ -58,8 +59,8 @@ if (!function_exists('base_url')) {
/**
* Возвращает базовый URL приложения.
*
* @param string $route Дополнительный маршрут, который будет добавлен к базовому URL.
* @return string Полный URL.
* @param string $route дополнительный маршрут, который будет добавлен к базовому URL
* @return string полный URL
*/
function base_url(string $route = ''): string
{
@@ -71,7 +72,7 @@ if (!function_exists('kernel')) {
/**
* Возвращает синглтон-экземпляр ядра приложения.
*
* @return Kernel Экземпляр ядра приложения.
* @return Kernel экземпляр ядра приложения
*/
function kernel(): Kernel
{
@@ -83,7 +84,7 @@ if (!function_exists('app')) {
/**
* Возвращает синглтон-экземпляр Slim-приложения.
*
* @return App Экземпляр Slim-приложения.
* @return App экземпляр Slim-приложения
*/
function app(): App
{
@@ -95,9 +96,9 @@ if (!function_exists('config')) {
/**
* Возвращает значение из конфигурации приложения.
*
* @param string $key Ключ конфигурации.
* @param mixed $default Значение по умолчанию, если ключ не найден.
* @return mixed Значение конфигурации или значение по умолчанию.
* @param string $key ключ конфигурации
* @param mixed $default значение по умолчанию, если ключ не найден
* @return mixed значение конфигурации или значение по умолчанию
*/
function config(string $key, mixed $default = null): mixed
{
@@ -109,7 +110,7 @@ if (!function_exists('redis')) {
/**
* Возвращает синглтон-экземпляр Redis-клиента.
*
* @return Redis Экземпляр Redis-клиента.
* @return Redis экземпляр Redis-клиента
*/
function redis(): Redis
{
@@ -121,7 +122,7 @@ if (!function_exists('ini')) {
/**
* Возвращает синглтон-экземпляр IniFile.
*
* @return IniFile Экземпляр IniFile.
* @return IniFile экземпляр IniFile
*/
function ini(): IniFile
{
@@ -273,11 +274,11 @@ if (!function_exists('env')) {
/**
* Возвращает значение переменной окружения.
*
* @param string $key Ключ переменной окружения.
* @param mixed $default Значение по умолчанию, если переменная не найдена.
* @param bool $required Бросать исключение, если переменная обязательна и не найдена.
* @return mixed Значение переменной окружения или значение по умолчанию.
* @throws InvalidArgumentException Если переменная обязательна и не найдена.
* @param string $key ключ переменной окружения
* @param mixed $default значение по умолчанию, если переменная не найдена
* @param bool $required бросать исключение, если переменная обязательна и не найдена
* @return mixed значение переменной окружения или значение по умолчанию
* @throws InvalidArgumentException если переменная обязательна и не найдена
*/
function env(string $key, mixed $default = null, bool $required = false): mixed
{
@@ -311,7 +312,7 @@ if (!function_exists('here')) {
* @param bool $asArray массив или строка в формате `<file|class>:<func>():<line>`
* @return string|array
*/
function here(bool $asArray = false): string|array
function here(bool $asArray = false): array|string
{
$trace = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2);
@@ -376,7 +377,7 @@ if (!function_exists('snake2camel')) {
}
}
if (!function_exists('as_data_url')) {
if (!function_exists('data_stream')) {
/**
* Создает data URL для данных
*
@@ -384,9 +385,9 @@ if (!function_exists('as_data_url')) {
* @param string $mimeType MIME-тип данных
* @return string Data URL
*/
function as_data_url(string $data, string $mimeType = 'text/plain'): string
function data_stream(string $data, string $mimeType = 'text/plain'): string
{
return "data://$mimeType,$data";
return "data://{$mimeType},{$data}";
}
}
@@ -473,7 +474,7 @@ if (!function_exists('number_format_local')) {
* @return string Отформатированное число
*/
function number_format_local(
int|float $number,
float|int $number,
int $decimals = 0,
string $decPoint = '.',
string $thousandsSep = ' ',
@@ -494,7 +495,7 @@ if (!function_exists('format_bytes')) {
{
$units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; ++$i) {
$bytes /= 1024;
}
@@ -581,11 +582,12 @@ if (!function_exists('get_noun_form')) {
if ($lastDigit === 1) {
return $form1;
} elseif ($lastDigit >= 2 && $lastDigit <= 4) {
return $form2;
} else {
return $form5;
}
if ($lastDigit >= 2 && $lastDigit <= 4) {
return $form2;
}
return $form5;
}
}
@@ -605,7 +607,7 @@ if (!function_exists('random_string')) {
$result = '';
$max = strlen($chars) - 1;
for ($i = 0; $i < $length; $i++) {
for ($i = 0; $i < $length; ++$i) {
$result .= $chars[random_int(0, $max)];
}
@@ -702,7 +704,7 @@ if (!function_exists('recast')) {
function recast(string $className, stdClass &$object): mixed
{
if (!class_exists($className)) {
throw new InvalidArgumentException("Class not found: $className");
throw new InvalidArgumentException("Class not found: {$className}");
}
$new = new $className();
@@ -769,7 +771,7 @@ if (!function_exists('mb_count_chars')) {
for ($i = 0; $i < $length; ++$i) {
$char = mb_substr($string, $i, 1, 'UTF-8');
!array_key_exists($char, $unique) && $unique[$char] = 0;
$unique[$char]++;
++$unique[$char];
}
return $unique;
@@ -853,7 +855,7 @@ if (!function_exists('array_last')) {
return $array[$lastKey];
}
for ($i = count($keys) - 1; $i >= 0; $i--) {
for ($i = count($keys) - 1; $i >= 0; --$i) {
$key = $keys[$i];
if ($callback($array[$key], $key)) {
return $array[$key];
@@ -916,7 +918,7 @@ if (!function_exists('array_get')) {
* @param mixed $default Значение по умолчанию
* @return mixed Значение из массива или значение по умолчанию
*/
function array_get(array $array, string|int $key, mixed $default = null): mixed
function array_get(array $array, int|string $key, mixed $default = null): mixed
{
return $array[$key] ?? $default;
}
@@ -1118,7 +1120,7 @@ if (!function_exists('array_recursive_diff')) {
$aReturn[$key] = $aRecursiveDiff;
}
} else {
if ($value != $b[$key]) {
if ($value !== $b[$key]) {
$aReturn[$key] = $value;
}
}
@@ -1400,7 +1402,7 @@ if (!function_exists('flatten')) {
*
* @param array $tree Дерево (например, результат функции tree())
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
* @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
* @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
* @return array Плоский список
*/
function flatten(
@@ -1459,8 +1461,8 @@ if (!function_exists('clear_tree')) {
*
* @param array $node Нода, которая должна быть обработана
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
* @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
* @return null|array Обработанная нода с хотя бы одним потомком либо null
* @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
* @return array|null Обработанная нода с хотя бы одним потомком либо null
*/
function clear_tree(
array $node,