['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246']]); if ($cookie !== false && $cookie !== null && $cookie !== '') { $cookie = $cookieJar->setCookie(SetCookie::fromString((string) $cookie)); $client = new Client(['cookies' => $cookie, 'headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246']]); } try { $response = $client->get($url)->getBody()->getContents(); $jsonResponse = json_decode($response, true); if (json_last_error() === JSON_ERROR_NONE) { $response = $jsonResponse; } } catch (RequestException $e) { if (function_exists('config') && config('app.debug') === true) { Log::error($e->getMessage()); } $response = false; } catch (RuntimeException $e) { if (function_exists('config') && config('app.debug') === true) { Log::error($e->getMessage()); } $response = false; } return $response; } } if (! function_exists('makeFieldLinks')) { /** * @return string * * @throws Exception */ function makeFieldLinks(mixed $data, mixed $field, mixed $type) { $tmpArr = explode(', ', $data[$field]); $newArr = []; $i = 0; foreach ($tmpArr as $ta) { if (trim($ta) === '') { continue; } if ($i > 7) { break; } $newArr[] = ''.$ta.''; $i++; } return implode(', ', $newArr); } } if (! function_exists('getUserBrowseOrder')) { /** * @param string $orderBy * @return array */ function getUserBrowseOrder($orderBy): array { $order = ($orderBy === '' ? 'username_desc' : $orderBy); $orderArr = explode('_', $order); $orderField = match ($orderArr[0]) { 'email' => 'email', 'host' => 'host', 'createdat' => 'created_at', 'lastlogin' => 'lastlogin', 'apiaccess' => 'apiaccess', 'apirequests' => 'apirequests', 'grabs' => 'grabs', 'roles_id' => 'users_role_id', 'rolechangedate' => 'rolechangedate', default => 'username', }; $orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc'; return [$orderField, $orderSort]; } } if (! function_exists('getUserBrowseOrdering')) { /** * @return array */ function getUserBrowseOrdering(): array { return [ 'username_asc', 'username_desc', 'email_asc', 'email_desc', 'host_asc', 'host_desc', 'createdat_asc', 'createdat_desc', 'lastlogin_asc', 'lastlogin_desc', 'apiaccess_asc', 'apiaccess_desc', 'apirequests_asc', 'apirequests_desc', 'grabs_asc', 'grabs_desc', 'role_asc', 'role_desc', 'rolechangedate_asc', 'rolechangedate_desc', 'verification_asc', 'verification_desc', ]; } } if (! function_exists('getSimilarName')) { /** * @param string $name */ function getSimilarName($name): string { return implode(' ', \array_slice(str_word_count(str_replace(['.', '_', '-'], ' ', $name), 2), 0, 2)); } } if (! function_exists('human_filesize')) { /** * @param int $decimals */ function human_filesize(mixed $bytes, $decimals = 0): string { $size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; $factor = (int) floor((\strlen((string) $bytes) - 1) / 3); return round((float) sprintf("%.{$decimals}f", $bytes / (1024 ** $factor)), $decimals).@$size[$factor]; } } if (! function_exists('bcdechex')) { /** * @return string */ function bcdechex(mixed $dec) { $hex = ''; do { $last = bcmod($dec, '16'); $hex = dechex((int) $last).$hex; $dec = bcdiv(bcsub($dec, $last), '16'); } while ($dec > 0); return $hex; } } if (! function_exists('runCmd')) { /** * Run CLI command. * * * @param string $command * @param bool $debug * @return string */ function runCmd($command, $debug = false) { if ($debug) { echo '-Running Command: '.PHP_EOL.' '.$command.PHP_EOL; } $process = Process::fromShellCommandline('exec '.$command); $process->setTimeout(1800); $process->run(); $output = $process->getOutput(); if ($debug) { echo '-Command Output: '.PHP_EOL.' '.$output.PHP_EOL; } return $output; } } if (! function_exists('escapeString')) { function escapeString(mixed $string): string { return DB::connection()->getPdo()->quote($string); } } if (! function_exists('realDuration')) { function realDuration(mixed $milliseconds): string { $time = round($milliseconds / 1000); return sprintf('%02dh:%02dm:%02ds', floor($time / 3600), floor($time / 60 % 60), $time % 60); } } if (! function_exists('is_it_json')) { /** * @throws JsonException */ function is_it_json(mixed $isIt): bool { if (is_array($isIt)) { return false; } json_decode($isIt, true, 512, JSON_THROW_ON_ERROR); return json_last_error() === JSON_ERROR_NONE; } } if (! function_exists('getStreamingZip')) { /** * @param array $guids * * @throws Exception */ function getStreamingZip(array $guids = []): STS\ZipStream\Builder { $nzb = app(NzbService::class); $zipped = ZipStream::create(now()->format('Ymdhis').'.zip'); foreach ($guids as $guid) { $nzbPath = $nzb->nzbPath($guid); if ($nzbPath) { $nzbContents = unzipGzipFile($nzbPath); if ($nzbContents) { $filename = $guid; $r = Release::query()->where('guid', $guid)->first(); if ($r) { $filename = $r['searchname']; } $zipped->addRaw($nzbContents, $filename.'.nzb'); } } } return $zipped; } } if (! function_exists('release_flag')) { // Function inspired by c0r3@newznabforums adds country flags on the browse page. /** * @param string $text Text to match against. * @param string $page Type of page. browse or search. */ function release_flag(string $text, string $page): bool|string { $code = $language = ''; switch (true) { case stripos($text, 'Arabic') !== false: $code = 'PK'; $language = 'Arabic'; break; case stripos($text, 'Cantonese') !== false: $code = 'TW'; $language = 'Cantonese'; break; case preg_match('/Chinese|Mandarin|\bc[hn]\b/i', $text): $code = 'CN'; $language = 'Chinese'; break; case preg_match('/\bCzech\b/i', $text): $code = 'CZ'; $language = 'Czech'; break; case stripos($text, 'Danish') !== false: $code = 'DK'; $language = 'Danish'; break; case stripos($text, 'Finnish') !== false: $code = 'FI'; $language = 'Finnish'; break; case preg_match('/Flemish|\b(Dutch|nl)\b|NlSub/i', $text): $code = 'NL'; $language = 'Dutch'; break; case preg_match('/French|Vostfr|Multi/i', $text): $code = 'FR'; $language = 'French'; break; case preg_match('/German(bed)?|\bger\b/i', $text): $code = 'DE'; $language = 'German'; break; case preg_match('/\bGreek\b/i', $text): $code = 'GR'; $language = 'Greek'; break; case preg_match('/Hebrew|Yiddish/i', $text): $code = 'IL'; $language = 'Hebrew'; break; case preg_match('/\bHindi\b/i', $text): $code = 'IN'; $language = 'Hindi'; break; case preg_match('/Hungarian|\bhun\b/i', $text): $code = 'HU'; $language = 'Hungarian'; break; case preg_match('/Italian|\bita\b/i', $text): $code = 'IT'; $language = 'Italian'; break; case preg_match('/Japanese|\bjp\b/i', $text): $code = 'JP'; $language = 'Japanese'; break; case preg_match('/Korean|\bkr\b/i', $text): $code = 'KR'; $language = 'Korean'; break; case stripos($text, 'Norwegian') !== false: $code = 'NO'; $language = 'Norwegian'; break; case stripos($text, 'Polish') !== false: $code = 'PL'; $language = 'Polish'; break; case stripos($text, 'Portuguese') !== false: $code = 'PT'; $language = 'Portugese'; break; case stripos($text, 'Romanian') !== false: $code = 'RO'; $language = 'Romanian'; break; case stripos($text, 'Spanish') !== false: $code = 'ES'; $language = 'Spanish'; break; case preg_match('/Swe(dish|sub)/i', $text): $code = 'SE'; $language = 'Swedish'; break; case preg_match('/Tagalog|Filipino/i', $text): $code = 'PH'; $language = 'Tagalog|Filipino'; break; case preg_match('/\bThai\b/i', $text): $code = 'TH'; $language = 'Thai'; break; case stripos($text, 'Turkish') !== false: $code = 'TR'; $language = 'Turkish'; break; case stripos($text, 'Russian') !== false: $code = 'RU'; $language = 'Russian'; break; case stripos($text, 'Vietnamese') !== false: $code = 'VN'; $language = 'Vietnamese'; break; } if ($code !== '' && $page === 'browse') { return ''.$language.''; } if ($page === 'search') { if ($code === '') { return false; } return $code; } return ''; } } if (! function_exists('getReleaseCover')) { /** * Get the cover image URL for a release based on its type and ID * * @param object|array $release The release object or array * @return string The cover image URL or placeholder if no cover exists */ function getReleaseCover($release): string { $coverType = null; $coverId = null; // Helper function to get value from object or array $getValue = function ($data, $key) { if (is_array($data)) { return $data[$key] ?? null; } elseif (is_object($data)) { return $data->$key ?? null; } return null; }; // Determine cover type and ID based on category $imdbid = $getValue($release, 'imdbid'); $musicinfo_id = $getValue($release, 'musicinfo_id'); $consoleinfo_id = $getValue($release, 'consoleinfo_id'); $bookinfo_id = $getValue($release, 'bookinfo_id'); $gamesinfo_id = $getValue($release, 'gamesinfo_id'); $anidbid = $getValue($release, 'anidbid'); if (! empty($imdbid) && $imdbid > 0) { $coverType = 'movies'; $coverId = str_pad($imdbid, 8, '0', STR_PAD_LEFT); } elseif (! empty($musicinfo_id)) { $coverType = 'music'; $coverId = $musicinfo_id; } elseif (! empty($consoleinfo_id)) { $coverType = 'console'; $coverId = $consoleinfo_id; } elseif (! empty($bookinfo_id)) { $coverType = 'book'; $coverId = $bookinfo_id; } elseif (! empty($gamesinfo_id)) { $coverType = 'games'; $coverId = $gamesinfo_id; } elseif (! empty($anidbid) && $anidbid > 0) { $coverType = 'anime'; $coverId = $anidbid; } // Return the cover URL if we have a type and ID // The CoverController will handle serving the file or returning a placeholder if ($coverType && $coverId) { return url("/covers/{$coverType}/{$coverId}-cover.jpg"); } // Return placeholder image if no cover type/ID found return asset('assets/images/no-cover.png'); } } if (! function_exists('sanitize')) { /** * @param array $doNotSanitize * @param array $phrases */ function sanitize(array|string $phrases, array $doNotSanitize = []): string { if (! is_array($phrases)) { $wordArray = explode(' ', str_replace('.', ' ', $phrases)); } else { $wordArray = $phrases; } $keywords = []; $tempWords = []; foreach ($wordArray as $words) { $words = preg_split('/\s+/', $words); foreach ($words as $st) { if (Str::startsWith($st, ['!', '+', '-', '?', '*']) && Str::length($st) > 1 && ! preg_match('/([!+?\-*]){2,}/', $st)) { $str = $st; } elseif (Str::endsWith($st, ['+', '-', '?', '*']) && Str::length($st) > 1 && ! preg_match('/([!+?\-*]){2,}/', $st)) { $str = $st; } else { $str = Sanitizer::escape($st, $doNotSanitize); } $tempWords[] = $str; } $keywords = $tempWords; } return implode(' ', $keywords); } } if (! function_exists('formatBytes')) { /** * Format bytes into human-readable file size. * * @param int|float|null $bytes */ function formatBytes($bytes = 0): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max((int) $bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, 2).' '.$units[$pow]; } } if (! function_exists('csp_nonce')) { /** * Generate a CSP nonce for inline scripts * Uses the Laravel container to ensure the nonce is truly unique per request, * avoiding issues with static variables in PHP-FPM environments. */ function csp_nonce(): string { // Use Laravel's container to store the nonce per-request // This ensures the same nonce is used in both the CSP header and Blade templates if (! app()->bound('csp_nonce')) { app()->instance('csp_nonce', base64_encode(random_bytes(16))); } return app('csp_nonce'); } } if (! function_exists('userDate')) { /** * Format a date/time string according to the authenticated user's timezone * * @param string|null $date The date to format * @param string $format The format string (default: 'M d, Y H:i') * @return string The formatted date in user's timezone */ function userDate(?string $date, string $format = 'M d, Y H:i'): string { if (empty($date)) { return ''; } try { // Parse the date in the app's timezone (which should be UTC) // If dates in DB are stored in server timezone, they'll be parsed correctly $appTimezone = config('app.timezone', 'UTC'); $carbon = \Illuminate\Support\Carbon::parse($date, $appTimezone); // If user is authenticated and has a timezone set, convert to it if (\Illuminate\Support\Facades\Auth::check() && \Illuminate\Support\Facades\Auth::user()->timezone) { $carbon->setTimezone(\Illuminate\Support\Facades\Auth::user()->timezone); } return $carbon->format($format); } catch (\Exception $e) { return $date; } } } if (! function_exists('userDateDiffForHumans')) { /** * Format a date/time string as a human-readable diff according to the authenticated user's timezone * * @param string|null $date The date to format * @return string The formatted date diff in user's timezone */ function userDateDiffForHumans(?string $date): string { if (empty($date)) { return ''; } try { // Parse the date in the app's timezone (which should be UTC) // If dates in DB are stored in server timezone, they'll be parsed correctly $appTimezone = config('app.timezone', 'UTC'); $carbon = \Illuminate\Support\Carbon::parse($date, $appTimezone); // If user is authenticated and has a timezone set, convert to it if (\Illuminate\Support\Facades\Auth::check() && \Illuminate\Support\Facades\Auth::user()->timezone) { $carbon->setTimezone(\Illuminate\Support\Facades\Auth::user()->timezone); } return $carbon->diffForHumans(); } catch (\Exception $e) { return $date; } } } if (! function_exists('getAvailableTimezones')) { /** * Get a list of available timezones grouped by region * * @return array Array of timezones grouped by region */ function getAvailableTimezones(): array { $timezones = []; $regions = [ 'Africa' => \DateTimeZone::AFRICA, 'America' => \DateTimeZone::AMERICA, 'Antarctica' => \DateTimeZone::ANTARCTICA, 'Arctic' => \DateTimeZone::ARCTIC, 'Asia' => \DateTimeZone::ASIA, 'Atlantic' => \DateTimeZone::ATLANTIC, 'Australia' => \DateTimeZone::AUSTRALIA, 'Europe' => \DateTimeZone::EUROPE, 'Indian' => \DateTimeZone::INDIAN, 'Pacific' => \DateTimeZone::PACIFIC, ]; foreach ($regions as $name => $region) { $timezones[$name] = \DateTimeZone::listIdentifiers($region); } return $timezones; } } if (! function_exists('countryCode')) { /** * Get a country code for a country name. * * @return mixed */ function countryCode(string $country) { if (\strlen($country) > 2) { $code = CountryModel::whereFullName($country)->orWhere('name', $country)->first(['iso_3166_2']); if ($code !== null && isset($code['iso_3166_2'])) { return $code['iso_3166_2']; } } return ''; } } if (! function_exists('unzipGzipFile')) { /** * Unzip a gzip file, return the output. Return false on error / empty. * * @return bool|string */ function unzipGzipFile(string $filePath) { $string = ''; $gzFile = @gzopen($filePath, 'rb', false); if ($gzFile) { while (! gzeof($gzFile)) { $temp = gzread($gzFile, 1024); // Check for empty string. // Without this the loop would be endless and consume 100% CPU. // Do not set $string empty here, as the data might still be good. if (! $temp) { break; } $string .= $temp; } gzclose($gzFile); } return $string === '' ? false : $string; } } if (! function_exists('streamSslContextOptions')) { /** * Creates an array to be used with stream_context_create() to verify openssl certificates * when connecting to a tls or ssl connection when using stream functions (fopen/file_get_contents/etc). * * @param bool $forceIgnore Force ignoring of verification (useful for self-signed certs in development). * @return array Stream context options for SSL/TLS connections */ function streamSslContextOptions(bool $forceIgnore = false): array { $cafile = config('nntmux_ssl.ssl_cafile', ''); $capath = config('nntmux_ssl.ssl_capath', ''); $hasCustomCerts = $cafile !== '' || $capath !== ''; // Base options - either insecure (no certs configured) or configured $options = [ 'verify_peer' => ! $forceIgnore && $hasCustomCerts && config('nntmux_ssl.ssl_verify_peer', false), 'verify_peer_name' => ! $forceIgnore && $hasCustomCerts && config('nntmux_ssl.ssl_verify_host', false), 'allow_self_signed' => $forceIgnore ? true : config('nntmux_ssl.ssl_allow_self_signed', true), ]; // Add certificate paths if configured if ($hasCustomCerts && ! $forceIgnore) { if ($cafile !== '') { $options['cafile'] = $cafile; } if ($capath !== '') { $options['capath'] = $capath; } } // Additional security options for modern TLS $options['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; $options['disable_compression'] = true; // Prevent CRIME attacks $options['SNI_enabled'] = true; // Enable Server Name Indication // If we set the transport to tls and the server falls back to ssl, // the context options would be for tls and would not apply to ssl, // so set both tls and ssl context in case the server does not support tls. return ['tls' => $options, 'ssl' => $options]; } } if (! function_exists('getCoverURL')) { /** * Get cover URL for a release. Uses a short-lived in-memory cache to avoid * repeated filesystem file_exists() calls for the same cover during a single request. * * @param array $options */ function getCoverURL(array $options = []): string { static $coverCache = []; $defaults = [ 'id' => null, 'suffix' => '-cover.jpg', 'type' => '', ]; $options += $defaults; $fileSpecTemplate = '%s/%s%s'; $fileSpec = ''; if (! empty($options['id']) && \in_array( $options['type'], ['anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', 'music', 'preview', 'sample', 'tvrage', 'video', 'xxx'], false ) ) { $fileSpec = sprintf($fileSpecTemplate, $options['type'], $options['id'], $options['suffix']); $cacheKey = $options['type'].':'.$options['id']; if (! isset($coverCache[$cacheKey])) { $coverCache[$cacheKey] = file_exists(storage_path('covers/').$fileSpec); } if (! $coverCache[$cacheKey]) { $fileSpec = sprintf($fileSpecTemplate, $options['type'], 'no', $options['suffix']); } } return $fileSpec; } } if (! function_exists('fileInfo')) { /** * Return file type/info using magic numbers. * Try using `file` program where available, fallback to using PHP's finfo class. * * @param string $path Path to the file / folder to check. * @return string File info. Empty string on failure. * * @throws \Exception */ function fileInfo(string $path): string { $magicPath = config('nntmux_settings.magic_file_path'); if ($magicPath !== null && \Illuminate\Support\Facades\Process::run('which file')->successful()) { $magicSwitch = " -m $magicPath"; $output = runCmd('file'.$magicSwitch.' -b "'.$path.'"'); } else { $fileInfo = $magicPath === null ? finfo_open(FILEINFO_RAW) : finfo_open(FILEINFO_RAW, $magicPath); $output = finfo_file($fileInfo, $path); if (empty($output)) { $output = ''; } finfo_close($fileInfo); } return $output; } } if (! function_exists('cp437toUTF')) { /** * Convert Code page 437 chars to UTF. */ function cp437toUTF(string $string): string { return iconv('CP437', 'UTF-8//IGNORE//TRANSLIT', $string); } } if (! function_exists('imdb_trailers')) { /** * Fetches an embeddable video to a IMDB trailer from http://www.traileraddict.com. */ function imdb_trailers(mixed $imdbID): string { $xml = getRawHtml('https://api.traileraddict.com/?imdb='.$imdbID); if ($xml !== false && preg_match('#(v\.traileraddict\.com/\d+)#i', $xml, $html)) { return 'https://'.$html[1]; } return ''; } } if (! function_exists('showApiError')) { function showApiError(int $errorCode = 900, string $errorText = ''): mixed { $errorHeader = 'HTTP 1.1 400 Bad Request'; if ($errorText === '') { switch ($errorCode) { case 100: $errorText = 'Incorrect user credentials'; $errorHeader = 'HTTP 1.1 401 Unauthorized'; break; case 101: $errorText = 'Account suspended'; $errorHeader = 'HTTP 1.1 403 Forbidden'; break; case 102: $errorText = 'Insufficient privileges/not authorized'; $errorHeader = 'HTTP 1.1 401 Unauthorized'; break; case 103: $errorText = 'Registration denied'; $errorHeader = 'HTTP 1.1 403 Forbidden'; break; case 104: $errorText = 'Registrations are closed'; $errorHeader = 'HTTP 1.1 403 Forbidden'; break; case 105: $errorText = 'Invalid registration (Email Address Taken)'; $errorHeader = 'HTTP 1.1 403 Forbidden'; break; case 106: $errorText = 'Invalid registration (Email Address Bad Format)'; $errorHeader = 'HTTP 1.1 403 Forbidden'; break; case 107: $errorText = 'Registration Failed (Data error)'; $errorHeader = 'HTTP 1.1 400 Bad Request'; break; case 200: $errorText = 'Missing parameter'; $errorHeader = 'HTTP 1.1 400 Bad Request'; break; case 201: $errorText = 'Incorrect parameter'; $errorHeader = 'HTTP 1.1 400 Bad Request'; break; case 202: $errorText = 'No such function'; $errorHeader = 'HTTP 1.1 404 Not Found'; break; case 203: $errorText = 'Function not available'; $errorHeader = 'HTTP 1.1 400 Bad Request'; break; case 300: $errorText = 'No such item'; $errorHeader = 'HTTP 1.1 404 Not Found'; break; case 500: $errorText = 'Request limit reached'; $errorHeader = 'HTTP 1.1 429 Too Many Requests'; break; case 501: $errorText = 'Download limit reached'; $errorHeader = 'HTTP 1.1 429 Too Many Requests'; break; case 910: $errorText = 'API disabled'; $errorHeader = 'HTTP 1.1 401 Unauthorized'; break; default: $errorText = 'Unknown error'; $errorHeader = 'HTTP 1.1 400 Bad Request'; break; } } $response = "\n". '\n"; return response($response)->header('Content-type', 'text/xml')->header('Content-Length', (string) strlen($response))->header('X-NNTmux', 'API ERROR ['.$errorCode.'] '.$errorText)->header('HTTP/1.1', $errorHeader); } } if (! function_exists('getRange')) { function getRange(mixed $tableName): \Illuminate\Contracts\Pagination\LengthAwarePaginator // @phpstan-ignore missingType.generics { $range = \Illuminate\Support\Facades\DB::table($tableName); return $range->orderByDesc('created_at')->paginate(config('nntmux.items_per_page')); } } if (! function_exists('isValidNewznabNzb')) { /** * Validate if the content is a valid Newznab NZB file. * * @param string $content The NZB file content * @return bool True if valid NZB, false otherwise */ function isValidNewznabNzb(string $content): bool { // Check if content starts with valid XML declaration and contains NZB namespace if (empty($content)) { return false; } // Try to load as XML libxml_use_internal_errors(true); $xml = @simplexml_load_string($content); if ($xml === false) { libxml_clear_errors(); return false; } // Check for NZB namespace or nzb root element $namespaces = $xml->getNamespaces(true); $hasNzbNamespace = isset($namespaces['']) && str_contains($namespaces[''], 'nzb'); $isNzbRoot = strtolower($xml->getName()) === 'nzb'; libxml_clear_errors(); return $hasNzbNamespace || $isNzbRoot; } } if (! function_exists('cli')) { /** * Get the CLI helper instance for colored console output. * * This replaces the Blacklight\ColorCLI class with a helper function approach. * Usage: cli()->primary('message'), cli()->error('message'), etc. * * @return object The CLI helper object with all output methods */ function cli(): object { static $instance = null; if ($instance === null) { $instance = new class { public int $lastMessageLength = 0; public function debug(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function info(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function notice(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function warning(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function error(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function primary(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function header(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function alternate(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function tmuxOrange(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function primaryOver(string $str): void { echo "\033[32m{$str}\033[0m"; } public function headerOver(string $str): void { echo "\033[33m{$str}\033[0m"; } public function alternateOver(string $str): void { echo "\033[1;35m{$str}\033[0m"; } public function warningOver(string $str): void { echo "\033[31m{$str}\033[0m"; } public function progress(): object { return new class { private int $total = 0; private int $current = 0; private string $label = ''; public function total(int $total): self { $this->total = $total; return $this; } public function current(int $current, ?string $label = null): void { $this->current = $current; if ($label !== null) { $this->label = $label; } $this->display(); } public function advance(int $step = 1, ?string $label = null): void { $this->current += $step; if ($label !== null) { $this->label = $label; } $this->display(); } private function display(): void { $percentage = $this->total > 0 ? (int) (($this->current / $this->total) * 100) : 0; $bar = str_repeat('=', (int) ($percentage / 2)); $spaces = str_repeat(' ', 50 - (int) ($percentage / 2)); $label = $this->label ? " {$this->label}" : ''; echo "\r[{$bar}{$spaces}] {$percentage}%{$label}"; if ($this->current >= $this->total) { echo "\n"; } } }; } /** * Apply ANSI color code to a string and return it (does not render) */ public function ansiString(string $string, string $color): string { $colors = [ 'black' => '0;30', 'red' => '0;31', 'green' => '0;32', 'yellow' => '0;33', 'blue' => '0;34', 'magenta' => '0;35', 'cyan' => '0;36', 'white' => '0;37', ]; $code = $colors[$color] ?? '0;37'; return "\033[{$code}m{$string}\033[0m"; } public function overWriteHeader(string $message, bool $reset = false): void { if ($reset) { $this->lastMessageLength = 0; } echo str_repeat(\chr(8), $this->lastMessageLength); echo str_repeat(' ', $this->lastMessageLength); echo str_repeat(\chr(8), $this->lastMessageLength); $this->lastMessageLength = \strlen($message); $this->headerOver($message); } public function overWritePrimary(string $message, bool $reset = false): void { if ($reset) { $this->lastMessageLength = 0; } echo str_repeat(\chr(8), $this->lastMessageLength); echo str_repeat(' ', $this->lastMessageLength); echo str_repeat(\chr(8), $this->lastMessageLength); $this->lastMessageLength = \strlen($message); $this->primaryOver($message); } public function overWrite(string $message, bool $reset = false): void { if ($reset) { $this->lastMessageLength = 0; } echo str_repeat(\chr(8), $this->lastMessageLength); echo str_repeat(' ', $this->lastMessageLength); echo str_repeat(\chr(8), $this->lastMessageLength); $this->lastMessageLength = \strlen($message); echo $message; } public function appendWrite(string $message): void { echo $message; $this->lastMessageLength += \strlen($message); } public function percentString(int $cur, int $total): string { $percent = 100 * $cur / $total; $formatString = '% '.\strlen((string) $total).'d/%d (% 2d%%)'; return sprintf($formatString, $cur, $total, $percent); } public function percentString2(int $first, int $last, int $total): string { $percent1 = 100 * ($first - 1) / $total; $percent2 = 100 * $last / $total; $formatString = '% '.\strlen((string) $total).'d-% '.\strlen((string) $total).'d/%d (% 2d%%-% 3d%%)'; return sprintf($formatString, $first, $last, $total, $percent1, $percent2); } /** * Convert seconds to minutes or hours, appending type at the end. */ public function convertTime(int $seconds): string { if ($seconds > 3600) { return round($seconds / 3600).' hour(s)'; } if ($seconds > 60) { return round($seconds / 60).' minute(s)'; } return $seconds.' second(s)'; } /** * Convert seconds to a timer, 00h:00m:00s. */ public function convertTimer(int $seconds): string { return ' '.sprintf('%02dh:%02dm:%02ds', floor($seconds / 3600), floor(($seconds / 60) % 60), $seconds % 60); } /** * Sleep for x seconds, printing timer on screen. */ public function showSleep(int $seconds): void { for ($i = $seconds; $i >= 0; $i--) { $this->overWriteHeader('Sleeping for '.$i.' seconds.'); sleep(1); } echo PHP_EOL; } }; } return $instance; } }