diff --git a/buildLanguages.php b/buildLanguages.php new file mode 100644 index 0000000..a7d31c8 --- /dev/null +++ b/buildLanguages.php @@ -0,0 +1,473 @@ +getMessage()); + exit(1); +} + +class buildLanguages +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * The directory where archives will be saved and additionally the temp files are handled here. + * @var string + */ + protected static string $output_dir = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + protected static bool $skip_download = false; + + protected static string $crowdin_api_key = ''; + + protected static array $crowdin_branch_map = [ + 'SMF_2-1' => ['2.1.0-alpha1','2.1.99'], + 'SMF_3-0' => ['3.0.0-alpha1','3.0.99'], + ]; + + /** + * Language map. SMF 3.0 will not use these and will just use the locale. + * This does not match (yet) the list in 3.0, as it matches what Crowodin export gives us. + * @var array + */ + protected static array $language_map = [ + 'af-ZA' => 'afrikaans', + 'sq-AL' => 'albanian', + 'ar-SA' => 'arabic', + 'bg-BG' => 'bulgarian', + 'ca-ES' => 'catalan', + 'zh-CN' => 'chinese_simplified', + 'zh-TW' => 'chinese_traditional', + 'hr-HR' => 'croatian', + 'cs' => 'czech_informal', + 'cs-CZ' => 'czech', + 'da-DK' => 'danish', + 'nl-NL' => 'dutch', + 'en-GB' => 'english_british', + 'eo-UY' => 'esperanto', + 'et-EE' => 'estonian', + 'fi-FI' => 'finnish', + 'fr-FR' => 'french', + 'gl-ES' => 'galician', + 'de' => 'german_informal', + 'de-DE' => 'german', + 'el-GR' => 'greek', + 'he-IL' => 'hebrew', + 'hu-HU' => 'hungarian', + 'id-ID' => 'indonesian', + 'it-IT' => 'italian', + 'ja-JP' => 'japanese', + 'kmr-TR' => 'kurdish_kurmanji', + 'lt-LT' => 'lithuanian', + 'mk-MK' => 'macedonian', + 'ms-MY' => 'malay', + 'no-NO' => 'norwegian', + 'fa-IR' => 'persian', + 'pl-PL' => 'polish', + 'pt-BR' => 'portuguese_brazilian', + 'pt-PT' => 'portuguese_pt', + 'ro-RO' => 'romanian', + 'ru-RU' => 'russian', + 'sr-SP' => 'serbian_cyrillic', + 'sr-CS' => 'serbian_latin', + 'sk-SK' => 'slovak', + 'sl-SI' => 'slovenian', + 'es-ES' => 'spanish_es', + 'es-MX' => 'spanish_latin', + 'sv-SE' => 'swedish', + 'th-TH' => 'thai', + 'tr-TR' => 'turkish', + 'uk-UA' => 'ukrainian', + 'ur-PK' => 'urdu', + 'vi-VN' => 'vietnamese', + 'eu' => 'basque', + 'bs-BA' => 'bosnian', + 'hi' => 'hindi', + 'ckb-IR' => 'kurdish_sorani', + 'lv-LV' => 'latvian', + 'ml-IN' => 'malayalam', + 'te' => 'telugu', + 'tk' => 'turkmen', + 'uz-UZ' => 'uzbek_latin', + 'az-AZ' => 'azerbaijani', + 'be-BY' => 'belarusian', + 'en-PT' => 'english_pirate', + 'ach' => 'acholi', + 'ug-CN' => 'uyghur' + ]; + /** + * A list of archives we will build. + * Currently this is: + * ZIP + * Tar.gz + * Tar.bz2 + * @var array + */ + protected static array $archives = [ + [Phar::ZIP, Phar::NONE], + [Phar::TAR, Phar::GZ], + [Phar::TAR, Phar::BZ2], + ]; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'o' => 'output_dir', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug', + 'key' => 'crowdin_api_key', + 'skip-download' => 'skip_download' + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + if (empty(self::$crowdin_api_key)) { + throw new Exception('Missing API Key'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + $smf_version = $version[1]; + $file_prefix = self::getFileNamePrefix($smf_version); + $tmp_file = self::$output_dir . '/' . $file_prefix . 'language_'; + + // Find out which Crowdin branch we are building. + // Pick our latest as the default. + $current_crowdin_project = array_key_last(self::$crowdin_branch_map); + foreach (self::$crowdin_branch_map as $k => $v) { + if (version_compare($v[0], $smf_version, '<=') && version_compare($v[1], $smf_version, '>=')) { + $current_crowdin_project = $k; + break; + } + } + + // Startup a new API with the Crowdin PHP API client. + if (!self::$skip_download) { + self::writeDebug('Connecting to Crowdin API'); + require_once(__DIR__ . '/vendor/autoload.php'); + $api = new \CrowdinApiClient\Crowdin([ + 'access_token' => self::$crowdin_api_key, + ]); + } + + // We sometimes may wan to skip a download, such as if we are rerunning this locally. + if (!self::$skip_download) + { + // Obtain project ID here.. + $project_id = $api->project->list()[0]->getId() ?? 0; + $project_identifier = $api->project->list()[0]->getIdentifier() ?? ''; + if (empty($project_id)) { + throw new Exception('Unable to obtain the project id'); + } + + /** @@todo Can we switch over to this? Simple Machines Download page should match these. + * Sample: ["es-ES"]=> array(1) { ["name"]=> string(10) "spanish_es" } + */ + //$lang_map = $api->project->list()[0]->getLanguageMapping(); + + $branch_id = $api->branch->list($project_id)[0]->getId() ?? 0; + if (empty($project_id)) { + throw new Exception('Unable to obtain the branch id'); + } + + // Ensure the project is built. + try + { + self::writeDebug('Starting Build'); + $results = $api->translation->buildProject( + $project_id, //$projectId : int + [ + 'branchId' => $branch_id, //integer $params[branchId] + //[],//array $params[targetLanguageIds] + 'skipUntranslatedStrings' => false, //bool $params[skipUntranslatedStrings] true value can't be used with skipUntranslatedFiles=true in same request + 'skipUntranslatedFiles' => false, //bool $params[skipUntranslatedFiles] true value can't be used with skipUntranslatedStrings=true in same request + 'exportApprovedOnly' => false, //bool $params[exportApprovedOnly] + //false, //integer $params[exportWithMinApprovalsCount] + ] + ); + } + catch (CrowdinApiClient\Exceptions\ApiException $e) + { + self::writeDebug($e->getMessage()); + var_dump($e); + throw $e; + } + catch (CrowdinApiClient\Exceptions\ApiValidationException $e) + { + $errs = $e->getErrors(); + foreach ($errs as $err) + self::writeDebug($err['error']['errors']); + throw $e; + } + + // Obtain the build id. + $buildID = $results->getId(); + + // We need to wait for it to build. + $done = false; + while (!$done) + { + $status = $api->translation->getProjectBuildStatus( + $project_id, //$projectId : int, + $buildID + ); + + if ($status->getProgress() > 99) + { + $done = true; + break; + } + + self::writeDebug('Building [' . $status->getProgress() . '%]'); + + sleep(10); + } + + self::writeDebug('Downloading archive [' . $tmp_file . "all.zip" . ']'); + $results = $api->translation->downloadProjectBuild( + $project_id, //$projectId : int, + $buildID + ); + $downloadURL = $results->getUrl(); + + file_put_contents($tmp_file . "all.zip", fopen($downloadURL, 'r')); + } + + if (!file_exists($tmp_file . "all.zip")) { + throw new Exception('Unable to locate language bundle[' . $tmp_file . "all.zip]"); + } + + // Extract it. + self::writeDebug('Extracting bundle'); + $zip = new ZipArchive; + if ($zip->open($tmp_file . "all.zip") === TRUE) { + $zip->extractTo($tmp_file . "tmp"); + $zip->close(); + } else { + throw new Exception('Unable to extract ZIP'); + } + + foreach (self::$language_map as $locale => $naming) { + self::writeDebug("[$locale] Building files"); + + $language_directory = $tmp_file . "tmp" . DIRECTORY_SEPARATOR . $locale; + + // Ensure we run a clean setup for the build. + array_map('unlink', glob($tmp_file . $locale . '*')); + + if (!file_exists($language_directory)) { + self::writeDebug("[$locale] Not found, skipping [" . $language_directory . ']'); + continue; + } + + $fileList = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $language_directory, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + fn ($file, $key, $iterator) => strpos($file->getPathname(), $language_directory) === 0, + ), + ); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[$locale] [$extension] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $locale . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[$locale] [$extension] Adding initial files"); + $pd->buildFromIterator($fileList, $language_directory); + + // Convert the archive into the proper archive and compression. + self::writeDebug("[$locale] [$extension] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[$locale] [$extension] Compressing"); + $zip = new ZipArchive; + $zip->open($tmp_file . $locale . '.' . $extension); + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $locale . '.tmp'); + } + } + } + + self::writeDebug('Cleaning up'); + @unlink($tmp_file . "all.zip"); + $tmp_files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp_file . "tmp"), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($tmp_files as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + @rmdir($tmp_file . "tmp"); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } else { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Build Release Tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp \n" + . '--s=/path/to/smf Where SMF has its files' . "\n" + . '--o=/path/to/out Where to store the generated files' . "\n" + . '--key=.... Crowdin API key, this defaults from the environment variable CROWDIN_API_TOKEN' . "\n" + . '-h, --help This help file.' . "\n" + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + self::$output_dir = self::$output_dir === '' ? realpath($_SERVER['PWD'] . '/..') : realpath(self::$output_dir); + self::$crowdin_api_key = self::$crowdin_api_key === '' ? getenv('CROWDIN_API_TOKEN') : self::$crowdin_api_key; + } + + /** + * Taking the SMF version found in index.php, we figure out how we would name the files. + * + * @param string $version + * @return string + */ + protected static function getFileNamePrefix(string $version): string + { + preg_match('~v?([\d]+)[-._]?([\d]+)[-._\s]?(alpha|beta|rc)?\.?\s?([\d]?)~i', $version, $matches); + + // Sometimes we used beta.1 instead of beta-1 + if (isset($matches[3]) && $matches[3] == 'beta.') { + $matches[3] = 'beta'; + } + + $prefix = 'smf_' . $matches[1] . '-' . $matches[2]; + + // 4 part name "SMF 2.0 Alpha 3" will produce [2, 0, 'Alpha', 3] + if (!empty($matches[4])) { + $prefix .= '-' . strtolower($matches[3]) . $matches[4]; + } elseif (!empty($matches[3])) { + $prefix .= '-' . $matches[3]; + } + + return $prefix . '_'; + } + + /** + * Write a debug output. + * + * @param string $msg + * @return void + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } +} diff --git a/buildPatch.php b/buildPatch.php new file mode 100644 index 0000000..e58f273 --- /dev/null +++ b/buildPatch.php @@ -0,0 +1,1115 @@ +getMessage()); + + exit(1); +} + +class buildPatch +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * ID of the tag we are starting from. + * @var string + */ + protected static string $from_tag = ''; + + /** + * ID of the tag we are going to. + * @var string + */ + protected static string $to_tag = ''; + + /** + * The directory where archives will be saved and additionally the temp files are handled here. + * @var string + */ + protected static string $output_dir = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + /** + * Should we verify the destination tag exists? + * + * @var bool + */ + protected static bool $no_verification = false; + + /** + * What type of patching we are doing. Either xml or diff + * @var + */ + protected static ?string $patch_type = null; + + /** + * Archive Options + * Yes = Build normal + * System = Use System binaries to build + * No = Do not build. + * + * @var string|null + */ + protected static string $archive_mode = 'yes'; + + /** + * SMF Version we are coming from. + * + * @var string + */ + protected static string $from_smf_version = ''; + + /** + * SMF Version we are going to. + * + * @var string + */ + protected static string $to_smf_version = ''; + + /** + * A list of archives we will build. + * Currently this is: + * ZIP + * Tar.gz + * Tar.bz2 + * @var array + */ + protected static array $archives = [ + [Phar::TAR, Phar::GZ], + ]; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'f' => 'from_tag', + 't' => 'to_tag', + 'o' => 'output_dir', + 'p' => 'patch_type', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug', + 'skip-verify' => 'no_verification', + 'name' => 'to_smf_version', + 'archive' => 'archive_mode', + ]; + + /** + * List of operations we perform to normalize the file name. + * Other directory is built and filtered later as the logic is easier to exclude after we have processed it. + * + * @var array + */ + protected static array $replacements = [ + '~^/Themes/default/scripts~i' => '$theme' . 'dir/scripts', + '~^/Themes/default/images~i' => '$images' . 'dir', + '~^/Themes/default/languages~i' => '$language' . 'dir', + '~^/Themes/default~i' => '$theme' . 'dir', + '~^/Sources~i' => '$source' . 'dir', + '~^/other~i' => '$board' . 'dir/other', + '~^/~i' => '$board' . 'dir/', + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + // Setting up our from version. + $from_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$from_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$from_tag); + } + // Get the version information. + $from_index = trim(shell_exec('git show ' . escapeshellarg(self::$from_tag . ':index.php')) ?? ''); + + // Validation of from version. + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $from_index, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$from_tag); + } + + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$from_tag); + } + self::$from_smf_version = $version[1]; + + // Setting up our to version + $to_tag_exists = self::$no_verification ? true : trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$to_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + + if (empty($to_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$to_tag); + } + + // Get the version information. + if (empty(self::$to_smf_version)) { + $to_index = trim(shell_exec('git show ' . escapeshellarg(self::$to_tag . ':index.php')) ?? ''); + + // Validation of from version. + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $to_index, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$to_tag); + } + + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$to_tag); + } + self::$to_smf_version = $version[1]; + } + + // Additional variables we need. + $to_file_prefix = self::getFileNamePrefix(self::$to_smf_version); + $to_php_version = self::getPhpMinimumVersion(self::$to_tag); + + // Ensure we have a sane patch type. + if (self::$patch_type === null || !in_array(self::$patch_type, ['xml', 'diff'])) { + self::$patch_type = version_compare(self::$to_smf_version, '3.0.0-alpha1', '<') ? 'xml' : 'diff'; + } + + self::writeDebug('[patch] Creating working folder'); + $tmp_dir = self::$output_dir . '/' . $to_file_prefix . 'patch' . DIRECTORY_SEPARATOR; + + if (empty($tmp_dir)) { + throw new Exception('Temp directory name missing'); + } + + // Cleanup any previous runs. + @array_map('unlink', glob($tmp_dir . '/*')); + @rmdir($tmp_dir); + @mkdir($tmp_dir); + + // When we generate the patch, we may end up needing to do some special operations. + $info_operations = []; + + self::writeDebug('[patch] Generating diff'); + shell_exec('git diff -p -M -C -C -B --default-prefix --no-relative ' . escapeshellarg(self::$from_tag) . '...' . escapeshellarg(self::$to_tag) . ' > ' . escapeshellcmd($tmp_dir . $to_file_prefix . 'patch.diff')); + + // Running something below 3.0 + if (self::$patch_type === 'xml') { + self::writeDebug('[patch] Converting to xml'); + self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', self::$to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); + + if (strtolower(self::$archive_mode) !== 'no' || !self::$debug) { + self::writeDebug(msg: '[patch] Cleaning up diff'); + unlink($tmp_dir . $to_file_prefix . 'patch.diff'); + } + + // Sort the output so its more organized. + self::sortPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); + + // Apply some qualify of life fixes. + self::cleanupPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); + } + + // Template for our package info file. + self::writeDebug('[patch] Building info file'); + $infoFileContents = self::packageInfoTemplate(self::$to_smf_version, $to_file_prefix, self::$from_smf_version, $to_php_version, self::$patch_type, $info_operations); + + self::writeDebug(msg: '[patch] Writing info file'); + file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); + + $tmp_file = self::$output_dir . '/' . $to_file_prefix; + $build = 'patch'; + + if (strtolower(self::$archive_mode) === 'no') { + self::writeDebug('[patch] Not building archive'); + + exit; + } + + // Ensure we run a clean setup for the build. + @array_map('unlink', glob($tmp_file . $build . '.*')); + + if (strtolower(self::$archive_mode) === 'system') { + self::archiveUsingSystemTools($tmp_file, $tmp_dir, $build); + } else { + self::archiveWithPhar($tmp_file, $tmp_dir, $build); + } + + // Cleanup. + @array_map('unlink', glob($tmp_dir . '/*')); + @array_map('unlink', glob($tmp_dir . '/.*')); + @rmdir($tmp_dir); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + + if (!isset(self::$cli_param_map[ltrim($var, '-')])) { + continue; + } + + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } elseif (isset(self::$cli_param_map[ltrim($param, '-')])) { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Build Release Tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp -f=3.0.1 -t=3.0.2 \n" + . '-s=/path/to/smf Where SMF has its files' . "\n" + . '-o=/path/to/out Where to store the generated files' . "\n" + . '-f=tag_id Tag in git for our source version.' . "\n" + . '-t=tag_id Tag in git for our target version.' . "\n" + . '-p=xml The of patch file (xml or diff)' . "\n" + . '--skip-verify Skips verification of destination tag.' . "\n" + . '--name=VERSION Provides an alternative name for the destination version.' . "\n" + . '--archive=yes Archive building. Yes (default): build using PHP Phar; No: Do not build; System: Build using the operation system tools' . "\n" + . '-h, --help This help file.' . "\n" + . '-d, --debug Prints out more debug info.' . "\n" + + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + self::$output_dir = self::$output_dir === '' ? realpath($_SERVER['PWD'] . '/..') : realpath(self::$output_dir); + } + + /** + * Taking the SMF version found in index.php, we figure out how we would name the files. + * + * @param string $version + * @return string + */ + protected static function getFileNamePrefix(string $version): string + { + preg_match('~v?([\d]+)[-._]?([\d]+)[-._\s]?(alpha|beta|rc)?\.?\s?([\d]?)~i', $version, $matches); + + // Sometimes we used beta.1 instead of beta-1 + if (isset($matches[3]) && $matches[3] == 'beta.') { + $matches[3] = 'beta'; + } + + $prefix = 'smf_' . $matches[1] . '-' . $matches[2]; + + // 4 part name "SMF 2.0 Alpha 3" will produce [2, 0, 'Alpha', 3] + if (!empty($matches[4])) { + $prefix .= '-' . strtolower($matches[3]) . $matches[4]; + } elseif (!empty($matches[3])) { + $prefix .= '-' . $matches[3]; + } + + return $prefix . '_'; + } + + /** + * Write a debug output. + * + * @param string $msg + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } + + /** + * Generates the package-info.xml File + * + * @param string $version SMF version we are going to. + * @param string $file_version SMF version file prefix. + * @param string $previous_version The previous SMF version (friendly) + * @param string $min_php_version Minimum version of PHP supported for the version we are going to. + * @param array $info_operations Additional operations to perform. + * @return string XML data for package-info.xml + */ + protected static function packageInfoTemplate(string $version, string $file_version, string $previous_version, string $min_php_version, string $extension, array $info_operations) + { + $template = << + + + smf:smf-{$version} + SMF {$version} Update + 1.0 + modification + + + This will update your forum to SMF {$version}. + '{$version}')); + ?>]]> + {$file_version}patch.{$extension} + END; + + foreach ($info_operations['remove-file'] ?? [] as $rm) { + $template .= ' + '; + } + + $template .= << + + This will remove the changes introduced by SMF {$version}. [b]This is generally not a good idea.[/b] + {$file_version}patch.{$extension} + '{$previous_version}'));]]> + END; + + foreach ($info_operations['remove-file'] ?? [] as $rm) { + $file_base = basename($rm); + $file_path = dirname($rm); + + $template .= ' + '; + } + + $template .= ' + +'; + + return $template; + } + + /** + * Given a git tag, find the minimum version of PHP it supports. + * This will search in locations for SMF 3.0 (Sources/Maintenance/Maintenance.php) and 2.x (other/install.php) + * + * @param string $tag (git tag -l) + * @throws \Exception + * @return string Minimum PHP version supported + */ + protected static function getPhpMinimumVersion(string $tag): string + { + // SMF 3.0 way. + $maintenance_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':Sources/Maintenance/Maintenance.php') . ' 2> /dev/null || echo ""') ?? ''); + + if (!empty($maintenance_file)) { + if (!preg_match('/public\s*const\s*PHP_MIN_VERSION\s*=\s*\'([^\']+)\';/i', $maintenance_file, $version)) { + throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); + } + + return $version[1]; + } + + // SMF 2.1 and below. + $install_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':other/install.php') . ' 2> /dev/null || echo ""') ?? ''); + + if (empty($install_file)) { + throw new Exception('Error: Unable to read contents of installer in ' . $tag); + } + + if (!preg_match('/\$GLOBALS\[\'required_php_version\'\]\s*=\s*\'([^\']+)\';/i', $install_file, $version)) { + throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); + } + + return $version[1]; + } + + /** + * Given the contents of a diff file, attempt to parse our a valid XML data file. + * + * @param string $diff_file File path to the diff file. + * @param string $version SMF Version we are going to. + * @param string $working_dir Working directory. + * @param string $to_file_prefix File prefix for this patch. + * @param array &$info_operations Operations passed onto our package-info.xml + */ + protected static function convertDiffToPatch(string $diff_file, string $version, string $working_dir, string $to_file_prefix, array &$info_operations): void + { + $content = file($diff_file); + $file_operations = []; + $operations = []; + $counter = 0; + $opCounter = 0; + $lineStart = 0; + $removes = 0; + $infoOperation = null; + + // First walk each line to figure out what we are doing. + for ($i = 0; $i < count($content); $i++) { + // Trigger a new file operation. + if (str_starts_with($content[$i], '--- a/')) { + $rawFile = trim(substr($content[$i], 5)); + $file = preg_replace( + array_keys(self::$replacements), + array_values(self::$replacements), + $rawFile, + ); + + $operations[$counter]['path'] = $file; + $operations[$counter]['file'] = $rawFile; + + // Is this a file deletion? + if (str_starts_with($content[$i + 1], '+++ /dev/null')) { + $file_operations['replace'] = ['']; + $infoOperation = basename($file); + $info_operations['remove-file'][] = $file; + } + + while (!str_starts_with($content[$i + 1], '@@')) { + $i++; + } + continue; + } + + /* + * When we end a block of code, tie it off and add it as a operation + * We do this when we detect: + * A new block (@@) + * A new file (diff --git) + * No more operations / EOF + * + * @author emanuele + * @copyright 2012 emanuele, Simple Machines + * @license http://www.simplemachines.org/about/smf/license.php BSD + */ + + // Appearing to start a new section, tie things off. + if ( + ( + str_starts_with($content[$i], '@@') + || str_starts_with($content[$i], 'diff --git') + || !isset($content[$i + 1]) + ) && !empty($file_operations) + ) { + // If this was a special info operation, don't do this. + if ($infoOperation !== null) { + self::writeDebug("[patch] Writing file {$infoOperation}"); + + file_put_contents($working_dir . DIRECTORY_SEPARATOR . $infoOperation, $file_operations['search']); + unset($operations[$counter]); + $infoOperation = null; + } else { + $operations[$counter]['operations'][$opCounter]['search'] = str_replace([''], [''], implode('', $file_operations['search'])); + $operations[$counter]['operations'][$opCounter]['replace'] = str_replace([''], [''], implode('', $file_operations['replace'])); + $operations[$counter]['operations'][$opCounter]['action'] = 'replace'; + $operations[$counter]['operations'][$opCounter]['lineStart'] = $lineStart; + $operations[$counter]['operations'][$opCounter]['removes'] = $removes; + + $opCounter++; + + // Get information about where the change is going. + if (str_starts_with($content[$i], '@@')){ + preg_match('/@@ -(\d{1,10}),{0,1}(\d{0,10}) \+\d{1,10},{0,1}\d{0,10} @@/', $content[$i], $matches); + $lineStart = $matches[1] ?? 0; + $removes = $matches[2] ?? 0; + } + + if (str_starts_with($content[$i], 'diff --git')) { + $file = ''; + $counter++; + } + } + + $file_operations = []; + continue; + } + + // Get information about where the change is going. + if (str_starts_with($content[$i], '@@')){ + preg_match('/@@ -(\d{1,10}),{0,1}(\d{0,10}) \+\d{1,10},{0,1}\d{0,10} @@/', $content[$i], $matches); + $lineStart = $matches[1] ?? 0; + $removes = $matches[2] ?? 0; + } + + if (!empty($file)) { + if (str_starts_with($content[$i], ' ')) { + $file_operations['replace'][] = $file_operations['search'][] = substr($content[$i], 1); + } + + if (str_starts_with($content[$i], '-')) { + $file_operations['search'][] = substr($content[$i], 1); + } elseif (str_starts_with($content[$i], '+')) { + $file_operations['replace'][] = substr($content[$i], 1); + } + } + } + + // Build the data. + $ret = ' + + + + smf:' . $version . ' + 1.0'; + + foreach ($operations as $file) { + // We only really care about php, js and css files. + if (str_starts_with($file['path'], '$board' . 'dir/other') || !in_array(pathinfo($file['path'], PATHINFO_EXTENSION), ['php', 'css', 'js'])) { + continue; + } + + $ret .= ' + + '; + + foreach ($file['operations'] as $file_operations) { + $ret .= ' + + + + '; + } + + $ret .= ' + '; + } + + $ret .= ' +'; + + self::writeDebug('[patch] Writing patch.xml'); + file_put_contents($working_dir . DIRECTORY_SEPARATOR . $to_file_prefix . 'patch.xml', $ret); + } + + /** + * Optimize operations on a single file. + * + * @param array $ops + * @return void + */ + protected static function performOptimizations(array &$fileOp): void { + $oldFileContents = shell_exec('git show ' . self::$from_tag . ':' . ltrim($fileOp['file'], '/')); + if (empty($oldFileContents)) { + return; + } + + $newFileContents = shell_exec('git show ' . self::$to_tag . ':' . ltrim($fileOp['file'], '/')); + if (empty($newFileContents)) { + return; + } + + array_walk($fileOp['operations'], fn($op) => self::trimOperations($op)); + + self::makeOperationsUnique($fileOp['operations'], $oldFileContents, $newFileContents); + } + + /** + * Attempts to trim our operation down a bit by removing some extra lines added from the diff conversion process. + * + * @author sbulen + * @param array $op + * @return void + */ + protected static function makeOperationsUnique(array &$ops, string $oldFile, string $newFile): void + { + $oldFileArray = explode("\n", $oldFile); + + foreach ($ops as $ix => &$op) { + // No search string for these + if (in_array($op['action'], array('end', 'new file'))) { + continue; + } + + // Keep adding lines until the search is unambiguous + // For 'replace', add to both remove & add; for before/after, etc., only to the search criterion + // If empty, add a line to prime the pump... + $line = $op['lineStart'] - 2; + if ( + empty($op['search']) + || ( + $op['action'] == 'replace' + && empty($op['replace']) + ) + ) { + $op['search'] = $oldFileArray[$line] . "\n" . $op['search']; + + if ($op['action'] == 'replace') { + $op['replace'] = $oldFileArray[$line] . "\n" . $op['replace']; + } + + $line--; + + // Keep status current... + $op['lineStart']--; + + if (isset($op['removes'])) { + $op['removes']++; + } + } + + $count = substr_count($oldFile, $op['search']); + + if ($op['action'] == 'replace') { + $uniqueness = substr_count($newFile, $op['replace']); + } + + // Cannot intrude upon updates from prior snippet... + $compareLine = ($ops[$ix - 1]['lineStart'] ?? 0) + ($ops[$ix - 1]['removes'] ?? 0) - 2; + + while (($count > 1 || ($op['action'] == 'replace' && $uniqueness > 1)) && $line > 0) { + if ($line > $compareLine) { + $op['search'] = $oldFileArray[$line] . "\n" . $op['search']; + + if ($op['action'] == 'replace') { + $op['replace'] = $oldFileArray[$line] . "\n" . $op['replace']; + } + + $line--; + + // Keep status current... + $op['lineStart']--; + + if (isset($op['removes'])) { + $op['removes']++; + } + + $count = substr_count($oldFile, $op['search']); + + if ($op['action'] == 'replace') { + $uniqueness = substr_count($newFile, $op['replace']); + } + } else { + // These must be resolved by hand at this point... + self::writeDebug('[ERROR] Cannot disambiguate operation'); + var_dump($op, $line, $compareLine); + die; + } + } + } + } + + private static int $contextLines = 3; + + /** + * Trim away some extra context. + * + * @author sbulen + * @param array $op + * @return void + */ + protected static function trimOperations(array &$op): void { + if (empty($op['action']) || $op['action'] !== 'replace') { + return; + } + + for ($i = 1; $i <= self::$contextLines; $i++) { + self::removeBottomLine($op); + self::removeTopLine($op); + } + } + + /** + * Remove Bottom Line - & make sure it's common + * + * @author sbulen + * @param array $op + * @return void + */ + protected static function removeBottomLine(array &$op): void + { + static $codeLine = '/(?<=\n|^)(.*\n?)$/D'; + + $sLine = preg_match($codeLine, $op['search'], $sMatch); + $rLine = preg_match($codeLine, $op['replace'], $rMatch); + + if ($sLine && $rLine && $sMatch[1] === $rMatch[1]) { + $op['search'] = substr($op['search'], 0, strlen($op['search']) - strlen($sMatch[1])); + $op['replace'] = substr($op['replace'], 0, strlen($op['replace']) - strlen($rMatch[1])); + + // Keep status current... + if (isset($op['removes'])) { + $op['removes']--; + } + } + } + + /** + * Remove Top Line - & make sure it's common + * + * @author sbulen + * @param mixed $op + * @return void + */ + protected static function removeTopLine(array &$op): void + { + // Get top lines from both... + $eolSearch = strpos($op['search'], "\n"); + $eolReplace = strpos($op['replace'], "\n"); + + if ($eolSearch !== false && $eolReplace !== false) { + $topSearch = substr($op['search'], 0, $eolSearch + 1); + $topReplace = substr($op['replace'], 0, $eolReplace + 1); + + if ($topSearch === $topReplace) { + // Don't remove comment lines, folks like those + if (substr(ltrim($topSearch), 0, 2) != '//') { + $op['search'] = substr($op['search'], $eolSearch + 1); + $op['replace'] = substr($op['replace'], $eolReplace + 1); + + // Keep status current... + $op['lineStart']++; + + if (isset($op['removes'])) { + $op['removes']--; + } + } + } + } + } + + /** + * Given a patch file, apply our XLS template to automatically sort the contents. + * @param string $output_file + */ + protected static function sortPatchFile(string $output_file): void + { + $xml1 = new DOMDocument(); + $xml1->load($output_file); + + $xslt = new DOMDocument(); + $xslt->loadXML(self::xlsTemplate()); + + $proc = new XSLTProcessor(); + $proc->importStylesheet($xslt); + $proc->transformToURI($xml1, 'file://' . $output_file); + } + + /** + * A template for sorting our XML output. + * Not required just makes things easier to read. + * + * @return string XML Template + */ + protected static function xlsTemplate(): string + { + $version = self::$to_smf_version; + + return << + + + + + + + + + + + + + + + + + + + + + + + + + <!-- {$version} updates for + + --> + + + + + + EOF; + } + + /** + * Perform some cleanup operations that just make things easier. + * + * @param string $output_file + */ + protected static function cleanupPatchFile(string $output_file): void + { + $contents = file_get_contents($output_file); + + // Be more precise with changes to license blocks. + // Takes a change to the copyright year and version, then breaks it into 2 operations. + $contents = preg_replace( + '~ + + + ~', + ' + + + + + + + ', + $contents, + ); + + // Additional version fixing. + // Takes the change for the SMF version and software year defines and breaks into 2 operations. + $contents = preg_replace( + '~ + + + ~', + ' + + + + + + + ', + $contents, + ); + + // Would you guess we are doing more cleaning of the version updates? + // Takes a version header and define updates and breaks it up into 4 operations. + ini_set('pcre.backtrack_limit', 10000000); + $contents = preg_replace( + '~ + + + ~', + ' + + + + + + + + + + + + + + + ', + $contents + ); + + // Get rid of useless ending newlines in replace statements. + $contents = preg_replace('~())*)\n(\]\]>\s+))*)\n(\]\]>)~', '$1$2$3$4$5', $contents); + + // Move ending newlines to start in before statements. + $contents = preg_replace('~())*)\n(\]\]>\s+))*)\n(\]\]>)~', '$1' . "\n" . '$2$3' . "\n" . '$4$5', $contents); + + file_put_contents($output_file, $contents); + } + + /** + * Build the archive using PHP's PHAR. + * + * @param string $tmp_file Prefix of filename we are building + * @param string $tmp_dir Directory containing the files we are archiving + * @param string $build Name of the build + */ + protected static function archiveWithPhar(string $tmp_file, string $tmp_dir, string $build): void + { + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[patch] [{$extension}] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $build . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[{$build}] [{$extension}] Adding initial files"); + $pd->buildFromIterator(new GlobIterator(pattern: $tmp_dir . DIRECTORY_SEPARATOR . '*'), $tmp_dir . DIRECTORY_SEPARATOR); + + // Convert the archive into the proper archive and compression. + self::writeDebug("[{$build}] [{$extension}] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[{$build}] [{$extension}] Compressing"); + $zip = new ZipArchive(); + $zip->open($tmp_file . $build . '.' . $extension); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $build . '.tmp'); + } + } + } + + protected static function archiveUsingSystemTools(string $tmp_file, string $tmp_dir, string $build): void + { + $current_directory = getcwd(); + + // Try to locate the tar binaries. + $tar_paths = ['/usr/bin/tar', '/bin/tar']; + $tar_path = array_filter($tar_paths, fn($bin) => file_exists($bin))[0] ?? null; + + if ($tar_path === null) { + throw new Exception('Unable to locate the tar binary'); + } + + // Try to locate the zip binaries. + $zip_paths = ['/usr/bin/zip', '/bin/zip']; + $zip_path = array_filter($zip_paths, fn($bin) => file_exists($bin))[0] ?? null; + + if ($zip_path === null) { + throw new Exception('Unable to locate the zip binary'); + } + + // Tar needs some extra args. + $tar_args = [ + '--no-xattrs', + '--no-acls', + '--exclude=\'.*\'', + ]; + + // Mac resource files and other garbage. + if (PHP_OS_FAMILY === 'Darwin') { + $tar_args[] = '--no-mac-metadata'; + $tar_args[] = '--no-fflags'; + } + + // Enter the working directory. + chdir($tmp_dir); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + self::writeDebug("[patch] [{$extension}] Building"); + + if ($a[0] === Phar::ZIP) { + shell_exec($zip_path . ' -x ".*/" -1 ' . $tmp_file . $build . '.zip -r *'); + } elseif ($a[0] === Phar::TAR && $a[1] === Phar::GZ) { + shell_exec($tar_path . ' ' . implode(' ', $tar_args) . ' -czf ' . $tmp_file . $build . '.tar.gz *'); + } elseif ($a[0] === Phar::TAR && $a[1] === Phar::BZ2) { + shell_exec($tar_path . ' ' . implode(' ', $tar_args) . ' -cjf ' . $tmp_file . $build . '.tar.bz *'); + } else { + throw new Exception('Unknown compression method'); + } + } + + // Return to where we started. + chdir($current_directory); + } +} diff --git a/buildRelease.php b/buildRelease.php new file mode 100644 index 0000000..56a2a91 --- /dev/null +++ b/buildRelease.php @@ -0,0 +1,397 @@ +getMessage()); + exit(1); +} + +class buildRelease +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * Which version of SMF we are working with. + * This allows this tool to handle multiple SMF versions. + * + * @var string + */ + protected static string $target_version = '30'; + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * The directory where archives will be saved and additionally the temp files are handled here. + * @var string + */ + protected static string $output_dir = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + /** + * List of files we will exclude. Grouping is + * all: All archives (install and upgrade) + * install: Files to ignore just for install + * upgrade: Files to ignore for upgrades + * @var array + */ + protected static array $ignoreFiles = [ + 'all' => [ + // Git files. + '.git', + '.git*', + + // System folders. + '.*', + '*/.DS_Store', + '*/._*', + + // SMF files. + 'favicon.ico', + 'other/*', + 'cache/data*', + 'cache/db_last_error.php', + 'custom_avatar/avatar*', + 'db_last_error.php', + + // Development files. + '.editorconfig', + 'DCO.txt', + '*.md', + 'error_log', + 'changelog.txt', + 'composer.*', + // If we ever include this, make sure we don't include developer files. + 'vendor/*', + ], + 'install' => [], + 'upgrade' => [ + 'agreement.txt', + ], + ]; + + /** + * List of files we will pull in from Other into our root archive. + * @var array + */ + protected static array $otherFiles = [ + 'install' => [ + 'readme.html', + 'install.php', + 'Settings.php', + 'Settings_bak.php', + // SMF 2.1 or below, SMF 3.0 does not make use of this. + 'install*.sql', + ], + 'upgrade' => [ + 'upgrade.php', + // SMF 2.1 or below, SMF 3.0 just uses upgrade.php + 'upgrade*.php', + 'upgrade*.sql', + ], + ]; + + /** + * A list of archives we will build. + * Currently this is: + * ZIP + * Tar.gz + * Tar.bz2 + * @var array + */ + protected static array $archives = [ + [Phar::ZIP, Phar::NONE], + [Phar::TAR, Phar::GZ], + [Phar::TAR, Phar::BZ2], + ]; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'v' => 'target_version', + 'o' => 'output_dir', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug' + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + $smf_version = $version[1]; + $file_prefix = self::getFileNamePrefix($smf_version); + $tmp_file = self::$output_dir . '/' . $file_prefix; + + foreach (['install', 'upgrade'] as $build) { + self::writeDebug("[$build] Building file list"); + + // Ensure we run a clean setup for the build. + array_map('unlink', glob($tmp_file . $build . '*')); + + // Builds our lists. + $fileList = self::generateFileList(self::$smf_root, array_merge(self::$ignoreFiles['all'], self::$ignoreFiles[$build])); + $otherList = self::generateOtherFilesList(self::$smf_root, self::$otherFiles[$build]); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[$build] [$extension] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $build . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[$build] [$extension] Adding initial files"); + $pd->buildFromIterator($fileList, self::$smf_root); + + // Add other into the root. + self::writeDebug("[$build] [$extension] Adding other files"); + foreach ($otherList as $item) { + $pd->addFile($item->getPathname(), $item->getFilename()); + } + + // Convert the archive into the proper archive and compression. + self::writeDebug("[$build] [$extension] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[$build] [$extension] Compressing"); + $zip = new ZipArchive; + $zip->open($tmp_file . $build . '.' . $extension); + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $build . '.tmp'); + } + } + } + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } else { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Build Release Tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp \n" + . '--s=/path/to/smf Where SMF has its files' . "\n" + . '--o=/path/to/out Where to store the generated files' . "\n" + . '--v=[30] What Version of SMF. This defaults to SMF 30.' . "\n" + . '-h, --help This help file.' . "\n" + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + self::$output_dir = self::$output_dir === '' ? realpath($_SERVER['PWD'] . '/..') : realpath(self::$output_dir); + } + + /** + * Taking the SMF version found in index.php, we figure out how we would name the files. + * + * @param string $version + * @return string + */ + protected static function getFileNamePrefix(string $version): string + { + preg_match('~v?([\d]+)[-._]?([\d]+)[-._\s]?(alpha|beta|rc)?\.?\s?([\d]?)~i', $version, $matches); + + // Sometimes we used beta.1 instead of beta-1 + if (isset($matches[3]) && $matches[3] == 'beta.') { + $matches[3] = 'beta'; + } + + $prefix = 'smf_' . $matches[1] . '-' . $matches[2]; + + // 4 part name "SMF 2.0 Alpha 3" will produce [2, 0, 'Alpha', 3] + if (!empty($matches[4])) { + $prefix .= '-' . strtolower($matches[3]) . $matches[4]; + } elseif (!empty($matches[3])) { + $prefix .= '-' . $matches[3]; + } + + return $prefix . '_'; + } + + /** + * Generate a list of files that are to be included in the main archive using a exclusion list. + * + * @param string $path + * @param array $ignores List of files we will exclude, these are based on the SMF root forward. + * @return RecursiveIteratorIterator + */ + protected static function generateFileList(string $path, array $ignores): RecursiveIteratorIterator + { + return new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $path, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + function ($file, $key, $iterator) use ($ignores, $path) { + // Simple is directory or exact matches. + if ($iterator->hasChildren() && !in_array($file->getFilename(), $ignores)) { + return true; + } + + // Work out the SMF root. + $filename = substr($file->getPathname(), 0, strlen($path)) === $path ? substr($file->getPathname(), strlen($path)) : $file->getPathname(); + + foreach ($ignores as $e) { + if (fnmatch($e, $filename)) { + return false; + } + } + + // Otherwise, only include this if its a file. + return $file->isFile(); + }, + ), + ); + } + + /** + * Generates a list of files that matches our filters to provide into the root of the archive from our other folder. + * + * @param string $path + * @param array $includes + * @return RecursiveIteratorIterator + */ + protected static function generateOtherFilesList(string $path, array $includes): RecursiveIteratorIterator + { + return new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $path . 'other' . DIRECTORY_SEPARATOR, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + function ($file, $key, $iterator) use ($includes) { + if (in_array($file->getFilename(), $includes)) { + return true; + } + + foreach ($includes as $e) { + if (fnmatch($e, $file->getFilename())) { + return true; + } + } + + return false; + }, + ), + ); + } + + /** + * Write a debug output. + * + * @param string $msg + * @return void + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } +} diff --git a/composer.json b/composer.json index 768c429..aead52f 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,8 @@ "name": "simplemachines/build-tools", "require": { "overtrue/phplint": "9.0.4", - "php": ">=8.0" + "php": ">=8.0", + "crowdin/crowdin-api-client": "^1.18" }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index eee086c..4659da8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,395 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a4fe00912e8466ebbe96641b8f1c1352", + "content-hash": "34f7a8dc76833e3a958f35f7e5e41172", "packages": [ + { + "name": "crowdin/crowdin-api-client", + "version": "1.18.0", + "source": { + "type": "git", + "url": "https://github.com/crowdin/crowdin-api-client-php.git", + "reference": "25b9dddfe1b99059e462bdde2d2cef5ed50d2bb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/crowdin/crowdin-api-client-php/zipball/25b9dddfe1b99059e462bdde2d2cef5ed50d2bb6", + "reference": "25b9dddfe1b99059e462bdde2d2cef5ed50d2bb6", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^6.2 || ^7", + "php": ">=7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "CrowdinApiClient\\FrameworkSupport\\Laravel\\CrowdinServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CrowdinApiClient\\": "src/CrowdinApiClient" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Crowdin", + "homepage": "https://crowdin.com" + } + ], + "description": "PHP client library for Crowdin API v2", + "homepage": "https://github.com/crowdin/crowdin-api-client-php", + "keywords": [ + "API-Client", + "api", + "client", + "crowdin", + "sdk" + ], + "support": { + "issues": "https://github.com/crowdin/crowdin-api-client-php/issues", + "source": "https://github.com/crowdin/crowdin-api-client-php/tree/1.18.0" + }, + "time": "2025-03-28T16:06:40+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:37:11+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:27:01+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" + }, { "name": "overtrue/phplint", "version": "9.0.4", @@ -236,6 +623,166 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/log", "version": "2.0.0", @@ -286,6 +833,50 @@ }, "time": "2021-07-14T16:41:46+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "symfony/cache", "version": "v5.4.31", diff --git a/updateHeaders.php b/updateHeaders.php new file mode 100644 index 0000000..a6d4ea1 --- /dev/null +++ b/updateHeaders.php @@ -0,0 +1,274 @@ +getMessage()); + + exit(1); +} + +class updateHeaders +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * ID of the tag we are starting from. + * @var string + */ + protected static string $from_tag = ''; + + /** + * ID of the branch we are going to. + * @var string + */ + protected static string $to_branch = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + /** + * SMF Version we are going to. + * + * @var string + */ + protected static string $to_smf_version = ''; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'f' => 'from_tag', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug', + 'name' => 'to_smf_version', + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + // Setting up our from version. + $from_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$from_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$from_tag); + } + + // Setting up our to version + $to_branch_exists = trim(shell_exec('git rev-parse --abbrev-ref HEAD') ?? ''); + + if (empty($to_branch_exists)) { + throw new Exception('Unable to tag for ' . self::$to_smf_version); + } + + // Get the version information. + if (empty(self::$to_smf_version)) { + throw new Exception('Error: Version is not stable in current branch'); + } + + $files = explode(PHP_EOL, shell_exec('git diff --name-only v2.1.6 release-2.1')); + + if (empty($files)) { + throw new Exception('Error: Unable to find any new files'); + } + + // Ensure we force update these files. + $files[] = 'index.php'; + $files[] = 'SSI.php'; + $files[] = 'proxy.php'; + $files[] = 'cron.php'; + $files[] = 'other/upgrade-helper.php'; + $files[] = 'other/upgrade.php'; + $files[] = 'other/install.php'; + + // Get the current year. + $current_year = date('Y', time()); + + foreach ($files as $file) { + if (empty($file) || !file_exists($file)) { + continue; + } + + if (str_starts_with($file, 'Sources/') || str_starts_with($file, 'Themes/default')) { + $length = 4000; + + $replacements = [ + '~(\r?\n\s+)\* @copyright \d{4} Simple Machines and individual contributors(\s+)~' => '\1* @copyright ' . $current_year . ' Simple Machines and individual contributors\2', + '~(\r?\n\s+)\* @version \d\.\d(?:\.\d)?(\s+)~' => '\1* @version ' . self::$to_smf_version . '\2', + ]; + } else if (str_starts_with($file, 'Themes/default/languages')) { + $length = 300; + + $replacements = [ + '~(\r?\n\s*)\/\/ Version: \d\.\d(?:\.\d)?;~' => '\1// Version: ' . self::$to_smf_version . ';' + ]; + } else if (in_array($file, ['index.php', 'cron.php', 'proxy.php', 'SSI.php']) || str_starts_with($file, 'other/')) { + $length = 4000; + + $replacements = [ + '~(\r?\n\s+)\* @copyright \d{4} Simple Machines and individual contributors(\s+)~' => '\1* @copyright ' . $current_year . ' Simple Machines and individual contributors\2', + '~(\r?\n\s+)\* @version \d\.\d(?:\.\d)?(\s+)~' => '\1* @version ' . self::$to_smf_version . '\2', + '~define\(\'SMF_VERSION\', \'([^\']+)\'\);~' => 'define(\'SMF_VERSION\', \'' . self::$to_smf_version . '\');' + ]; + } + else { + self::writeDebug('[SKIP] Unknown file {$file}'); + continue; + } + + // PHP doesn't offer a way to insert in the middle of a line. So we use a temp file. + self::writeDebug('[Updating] {$file}'); + $fr = fopen($file, 'r'); + $fw = fopen($file . '~', 'w+'); + + if ($fr === false || $fw === false) { + throw new Exception('Error: Unable to open file [' . $file . '] for read and write'); + } + + // The first read should have our header. + $contents = fread($fr, $length); + + // Perform replacements. + $contents = preg_replace(array_keys($replacements), array_values($replacements), $contents); + fwrite($fw,$contents); + + // Write out rest of the file. + while (!feof($fr)) { + fwrite($fw, fread($fr, $length)); + } + + fclose($fr); + fclose($fw); + + // Out with the old, in with the new. + unlink($file); + rename($file . '~', $file); + } + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + + if (!isset(self::$cli_param_map[ltrim($var, '-')])) { + continue; + } + + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } elseif (isset(self::$cli_param_map[ltrim($param, '-')])) { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Update Headers tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp -f=3.0.1 -t=3.0.2 \n" + . '-s=/path/to/smf Where SMF has its files' . "\n" + . '-f=tag_id Tag in git for our source version.' . "\n" + . '--name=VERSION SMF version to be named.' . "\n" + . '-h, --help This help file.' . "\n" + . '-d, --debug Prints out more debug info.' . "\n" + + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + } + + /** + * Write a debug output. + * + * @param string $msg + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } +}