From 0dc4cb971d17163eefe54bbfc0113a623198934f Mon Sep 17 00:00:00 2001 From: Benoit VIGNAL Date: Mon, 22 Dec 2025 11:01:34 +0100 Subject: [PATCH 1/7] Move `glpi-project/tools` command into GLPI --- .gettext.config.js | 24 + .github/actions/lint_misc-lint.sh | 4 +- Makefile | 10 +- PluginsMakefile.mk | 14 +- bin/console | 40 +- composer.json | 3 +- composer.lock | 90 +- package-lock.json | 128 +++ package.json | 4 +- src/Glpi/Application/ResourcesChecker.php | 18 +- src/Glpi/Console/CommandLoader.php | 2 +- .../LicenceHeadersCheckCommandTest.php | 156 ++++ .../Command/LocalesExtractCommandTest.php | 104 +++ tests/src/GLPITestCase.php | 31 +- tools/build_glpi.sh | 2 +- tools/src/Command/AbstractCommand.php | 121 +++ .../CheckTwigTemplatesSyntaxCommand.php | 2 +- .../Command/LicenceHeadersCheckCommand.php | 795 ++++++++++++++++++ tools/src/Command/LocalesCompileCommand.php | 122 +++ tools/src/Command/LocalesExtractCommand.php | 493 +++++++++++ .../Plugin/Command/AbstractPluginCommand.php | 74 ++ .../Plugin/Command/PluginReleaseCommand.php | 322 +++++++ 22 files changed, 2424 insertions(+), 135 deletions(-) create mode 100644 .gettext.config.js create mode 100644 tests/functional/Tools/Command/LicenceHeadersCheckCommandTest.php create mode 100644 tests/functional/Tools/Command/LocalesExtractCommandTest.php create mode 100644 tools/src/Command/AbstractCommand.php create mode 100644 tools/src/Command/LicenceHeadersCheckCommand.php create mode 100644 tools/src/Command/LocalesCompileCommand.php create mode 100644 tools/src/Command/LocalesExtractCommand.php create mode 100644 tools/src/Plugin/Command/AbstractPluginCommand.php create mode 100644 tools/src/Plugin/Command/PluginReleaseCommand.php diff --git a/.gettext.config.js b/.gettext.config.js new file mode 100644 index 000000000000..a9a9cc394165 --- /dev/null +++ b/.gettext.config.js @@ -0,0 +1,24 @@ +// eslint-disable-next-line no-undef +module.exports = { + input: { + path: "js", + include: ["**/*.vue"], + parserOptions: { + overrideDefaultKeywords: true, + mapping: { + simple: ["__"], + plural: ["_n"], + ctxPlural: ["_nx"], + ctx: ["_x"] + } + } + }, + output: { + path: "./", + potPath: "locales/vue.pot", + locales: [], + splitJson: false, + linguas: false, + flat: true + }, +}; diff --git a/.github/actions/lint_misc-lint.sh b/.github/actions/lint_misc-lint.sh index bb360f0bf744..0b2725236716 100755 --- a/.github/actions/lint_misc-lint.sh +++ b/.github/actions/lint_misc-lint.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -u -x -o pipefail -vendor/bin/licence-headers-check --ansi --no-interaction +bin/console tools:licence_headers_check -vendor/bin/extract-locales 2>&1 | tee extract.log +bin/console tools:locales:extract 2>&1 | tee extract.log if [[ -n $(grep "warning" extract.log) ]]; then exit 1; fi diff --git a/Makefile b/Makefile index 67827b9078c5..567643a08551 100644 --- a/Makefile +++ b/Makefile @@ -94,11 +94,11 @@ vendor: console .PHONY: vendor locales-extract: ## Extract locales - @$(PHP) vendor/bin/extract-locales + @$(CONSOLE) tools:locales:extract .PHONY: locales-extract -locales-compile: c=locales:compile ## Compile locales -locales-compile: console +locales-compile: + @$(CONSOLE) tools:locales:compile .PHONY: locales-compile cc: c=cache:clear ## Clear the cache @@ -106,11 +106,11 @@ cc: console .PHONY: cc license-headers-check: ## Verify that the license headers is present all files - @$(PHP) vendor/bin/licence-headers-check + @$(CONSOLE) tools:licence_headers_check .PHONY: license-headers-check license-headers-fix: ## Add the missing license headers in all files - @$(PHP) vendor/bin/licence-headers-check --fix + @$(CONSOLE) tools:licence_headers_check --fix .PHONY: license-headers-fix ## —— Database ————————————————————————————————————————————————————————————————— diff --git a/PluginsMakefile.mk b/PluginsMakefile.mk index 4d9dee8d2538..e3e63f346fe6 100644 --- a/PluginsMakefile.mk +++ b/PluginsMakefile.mk @@ -78,20 +78,26 @@ test-setup: ## Setup the plugin for tests .PHONY: test-setup locales-extract: ## Extract locales - @$(PLUGIN) vendor/bin/extract-locales + @$(CONSOLE) tools:locales:extract --plugin=$(PLUGIN_DIR) .PHONY: locales-extract locales-compile: ## Compile locales - @$(PLUGIN) vendor/bin/plugin-release --compile-mo + @$(CONSOLE) tools:locales:compile --plugin=$(PLUGIN_DIR) .PHONY: locales-compile +plugin-release: ## Build and create plugin release based on HEAD ref, example: make plugin-release n='1.0.12' + @$(eval n ?=) + @$(CONSOLE) tools:plugin:release --plugin=$(PLUGIN_DIR) --archive-name=$(n) +.PHONY: plugin-release + + ##—— Licenses ————————————————————————————————————————————————————————————————— license-headers-check: ## Verify that the license headers is present all files - @$(PLUGIN) vendor/bin/licence-headers-check + @$(CONSOLE) tools:licence_headers_check --plugin=$(PLUGIN_DIR) .PHONY: license-headers-check license-headers-fix: ## Add the missing license headers in all files - @$(PLUGIN) vendor/bin/licence-headers-check --fix + @$(CONSOLE) tools:licence_headers_check --fix --plugin=$(PLUGIN_DIR) .PHONY: license-headers-fix ##—— Dependencies —————————————————————————————————————————————————————————————— diff --git a/bin/console b/bin/console index 74ec51d835a3..b52974eb10bf 100755 --- a/bin/console +++ b/bin/console @@ -50,6 +50,12 @@ if (isset($_SERVER['argv'])) { $chunks[0] = preg_replace('/^--/', '', $chunks[0]); $options[$chunks[0]] = (isset($chunks[1]) ? $chunks[1] : true); } + + // Alias of previous locales compile command + // FIXME: remove in a later GLPI version + if (count($_SERVER['argv']) > 1 && $_SERVER['argv'][1] === 'locales:compile') { + $_SERVER['argv'][1] = 'tools:locales:compile'; + } } //Handle specific dependencies update command that cannot be made upon symfony console @@ -67,10 +73,6 @@ if (isset($_SERVER['argv']) && ['dependencies', 'install'] === array_slice($_SER exit($exit_code); } - $exit_code = compileLocales(); - if ($exit_code > 0) { - exit($exit_code); - } $npm_command = 'npm install --no-save'; passthru($npm_command, $exit_code); @@ -83,34 +85,12 @@ if (isset($_SERVER['argv']) && ['dependencies', 'install'] === array_slice($_SER exit($exit_code); } - exit($exit_code); -} - -// Handle specific "locales:compile" command that cannot be made upon symfony console -if (isset($_SERVER['argv']) && ($_SERVER['argv'][1] ?? '') === 'locales:compile') { - $exit_code = compileLocales(); - - exit($exit_code); -} - -function compileLocales(): int -{ - chdir(dirname(__FILE__, 2)); - - $exit_code = 0; - - $po_files = preg_grep('/\.po$/', scandir('./locales')); - echo "Compiling MO files..."; - foreach ($po_files as $po_file) { - $mo_file = preg_replace('/\.po$/', '.mo', $po_file); - passthru("cd ./locales && msgfmt -f -o {$mo_file} {$po_file}", $exit_code); - if ($exit_code > 0) { - exit($exit_code); - } + passthru('bin/console tools:locales:compile --allow-superuser', $exit_code); + if ($exit_code > 0) { + exit($exit_code); } - echo " Done.\n"; - return $exit_code; + exit($exit_code); } // Check the resources state before trying to instanciate the Kernel. diff --git a/composer.json b/composer.json index 3b7cfd83c112..e9761dc5a12a 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,6 @@ "friendsofphp/php-cs-fixer": "^3.86", "friendsoftwig/twigcs": "^6.5", "glpi-project/phpstan-glpi": "^1.1", - "glpi-project/tools": "^0.8.3", "mikey179/vfsstream": "^1.6", "php-parallel-lint/php-parallel-lint": "^1.4", "phpstan/phpstan": "^2.1", @@ -122,7 +121,9 @@ "shipmonk/composer-dependency-analyser": "^1.8", "symfony/browser-kit": "^6.4", "symfony/debug-bundle": "^6.4", + "symfony/finder": "^6.4", "symfony/http-client": "^6.4", + "symfony/process": "^6.4", "symfony/stopwatch": "^6.4", "symfony/twig-bundle": "^6.4", "symfony/var-dumper": "^6.4", diff --git a/composer.lock b/composer.lock index d8db732f9171..740721548e4b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e657cbb167ddee7793b25866f62f3e74", + "content-hash": "9ab271766d24b5ad31cde4eb8157b164", "packages": [ { "name": "altcha-org/altcha", @@ -6698,23 +6698,23 @@ }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v6.4.33", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "24965ca011dac87431729640feef8bcf7b5523e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/24965ca011dac87431729640feef8bcf7b5523e0", + "reference": "24965ca011dac87431729640feef8bcf7b5523e0", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^6.0|^7.0" }, "type": "library", "autoload": { @@ -6742,7 +6742,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v6.4.33" }, "funding": [ { @@ -6762,7 +6762,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-26T13:03:48+00:00" }, { "name": "symfony/framework-bundle", @@ -11071,62 +11071,6 @@ }, "time": "2025-08-11T13:48:13+00:00" }, - { - "name": "glpi-project/tools", - "version": "0.8.3", - "source": { - "type": "git", - "url": "https://github.com/glpi-project/tools.git", - "reference": "8ea2a7d4702a858f4b0360ba7d4f1841a5e77026" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/glpi-project/tools/zipball/8ea2a7d4702a858f4b0360ba7d4f1841a5e77026", - "reference": "8ea2a7d4702a858f4b0360ba7d4f1841a5e77026", - "shasum": "" - }, - "require": { - "symfony/console": "^5.4 || ^6.0", - "twig/twig": "^3.3" - }, - "require-dev": { - "nikic/php-parser": "^4.13", - "phpstan/phpstan-src": "^1.10" - }, - "bin": [ - "bin/extract-locales", - "bin/licence-headers-check", - "tools/plugin-release" - ], - "type": "library", - "autoload": { - "psr-4": { - "GlpiProject\\Tools\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-3.0-or-later" - ], - "authors": [ - { - "name": "Teclib'", - "email": "glpi@teclib.com", - "homepage": "http://teclib-group.com" - } - ], - "description": "Various tools for GLPI and its plugins", - "keywords": [ - "glpi", - "plugins", - "tools" - ], - "support": { - "issues": "https://github.com/glpi-project/tools/issues", - "source": "https://github.com/glpi-project/tools" - }, - "time": "2025-10-14T10:26:06+00:00" - }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -14347,20 +14291,20 @@ }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v6.4.33", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "c46e854e79b52d07666e43924a20cb6dc546644e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/c46e854e79b52d07666e43924a20cb6dc546644e", + "reference": "c46e854e79b52d07666e43924a20cb6dc546644e", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "type": "library", "autoload": { @@ -14388,7 +14332,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v6.4.33" }, "funding": [ { @@ -14408,7 +14352,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-23T16:02:12+00:00" }, { "name": "symfony/stopwatch", @@ -15024,5 +14968,5 @@ "platform-overrides": { "php": "8.2.99" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/package-lock.json b/package-lock.json index 8745ec9e9d84..384edda51fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,6 +121,7 @@ "typescript": "^5.9.3", "vue-eslint-parser": "^10.2.0", "vue-loader": "^17.4.2", + "vue3-gettext": "^4.0.0-beta.1", "webpack": "^5.100.2", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.3" @@ -6222,6 +6223,16 @@ "dequal": "^2.0.3" } }, + "node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -7305,6 +7316,30 @@ "node": ">= 0.8" } }, + "node_modules/command-line-args": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.2.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -9465,6 +9500,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -12711,6 +12764,19 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -12800,6 +12866,13 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -14050,6 +14123,13 @@ "node": ">= 6" } }, + "node_modules/pofile": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz", + "integrity": "sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -16794,6 +16874,16 @@ "node": ">=14.17" } }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -17111,6 +17201,44 @@ } } }, + "node_modules/vue3-gettext": { + "version": "4.0.0-beta.1", + "resolved": "https://registry.npmjs.org/vue3-gettext/-/vue3-gettext-4.0.0-beta.1.tgz", + "integrity": "sha512-1A46SmubgTMyy7i5hj8ay50NFl6/vzwoIVZPuGCin/X3a/NVCAs99G0EbcnfJiR7NZNTJgUjvBzppufC7Kq+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.0", + "command-line-args": "^6.0.1", + "glob": "^11.0.3", + "lilconfig": "^3.1.3", + "pofile": "^1.1.4" + }, + "bin": { + "vue-gettext-compile": "dist/gettext_compile.js", + "vue-gettext-extract": "dist/gettext_extract.js" + }, + "engines": { + "node": ">= 20.19.0" + }, + "peerDependencies": { + "@vue/compiler-sfc": ">=3.0.0", + "vue": ">=3.0.0" + } + }, + "node_modules/vue3-gettext/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index f144e377cbc5..56adbf4e407f 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,8 @@ "watch:vue": "webpack --config .vue.webpack.config.js --watch", "watch": "npm run watch:vue", "test:e2e": "cypress open --project tests", - "serve:vue": "webpack serve --config .vue.webpack.config.js" + "serve:vue": "webpack serve --config .vue.webpack.config.js", + "vue:gettext:extract": "vue-gettext-extract --config .gettext.config.js" }, "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.27.1", @@ -136,6 +137,7 @@ "typescript": "^5.9.3", "vue-eslint-parser": "^10.2.0", "vue-loader": "^17.4.2", + "vue3-gettext": "^4.0.0-beta.1", "webpack": "^5.100.2", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.3" diff --git a/src/Glpi/Application/ResourcesChecker.php b/src/Glpi/Application/ResourcesChecker.php index ce53d5533e1c..aefc7380e694 100644 --- a/src/Glpi/Application/ResourcesChecker.php +++ b/src/Glpi/Application/ResourcesChecker.php @@ -61,9 +61,9 @@ public function checkResources(): void echo 'Run "php bin/console dependencies install" in the glpi tree to fix this.' . PHP_EOL; exit(1); // @phpstan-ignore glpi.forbidExit (Script execution should be stopped to prevent further errors) } - if (!$this->areLocalesUpToDate()) { + if ($this->shouldLocalesBeChecked() && !$this->areLocalesUpToDate()) { echo 'Application locales have to be compiled.' . PHP_EOL; - echo 'Run "php bin/console locales:compile" in the glpi tree to fix this.' . PHP_EOL; + echo 'Run "php bin/console tools:locales:compile" in the glpi tree to fix this.' . PHP_EOL; exit(1); // @phpstan-ignore glpi.forbidExit (Script execution should be stopped to prevent further errors) } } @@ -94,22 +94,28 @@ private function areDependenciesUpToDate(): bool // Check composer dependencies $autoload = $this->root_dir . '/vendor/autoload.php'; if (!file_exists($autoload)) { + echo 'Missing composer autoload file.' . PHP_EOL; return false; } elseif (file_exists($this->root_dir . '/composer.lock')) { if (!file_exists($this->root_dir . '/.composer.hash')) { + echo 'Missing .composer.hash file.' . PHP_EOL; return false; } elseif (sha1_file($this->root_dir . '/composer.lock') != file_get_contents($this->root_dir . '/.composer.hash')) { // @phpstan-ignore theCodingMachineSafe.function, theCodingMachineSafe.function (Safe not installed at this point) + echo 'Composer hash not matching.' . PHP_EOL; return false; } } // Check node dependencies if (!file_exists($this->root_dir . '/public/lib')) { + echo 'Missing node /public/lib folder.' . PHP_EOL; return false; } elseif (file_exists($this->root_dir . '/package-lock.json')) { if (!file_exists($this->root_dir . '/.package.hash')) { + echo 'Missing .package.hash file.' . PHP_EOL; return false; } elseif (sha1_file($this->root_dir . '/package-lock.json') != file_get_contents($this->root_dir . '/.package.hash')) { // @phpstan-ignore theCodingMachineSafe.function, theCodingMachineSafe.function (Safe not installed at this point) + echo 'Node package hash not matching.' . PHP_EOL; return false; } } @@ -141,6 +147,14 @@ private function areLocalesUpToDate(): bool return true; } + /** + * Avoid checker blocking itself when running `tools:locales:compile` command. + */ + private function shouldLocalesBeChecked(): bool + { + return (PHP_SAPI === 'cli' && \str_starts_with($_SERVER['argv'][1] ?? '', 'tools:')) === false; + } + /** * Check if the resources should be checked. */ diff --git a/src/Glpi/Console/CommandLoader.php b/src/Glpi/Console/CommandLoader.php index 5cbb531051f8..84313814470d 100644 --- a/src/Glpi/Console/CommandLoader.php +++ b/src/Glpi/Console/CommandLoader.php @@ -291,7 +291,7 @@ private function findPluginCommands() /** * Find all "tools" commands. * - * return void + * @return void */ private function findToolsCommands(): void { diff --git a/tests/functional/Tools/Command/LicenceHeadersCheckCommandTest.php b/tests/functional/Tools/Command/LicenceHeadersCheckCommandTest.php new file mode 100644 index 000000000000..207b6dae60ee --- /dev/null +++ b/tests/functional/Tools/Command/LicenceHeadersCheckCommandTest.php @@ -0,0 +1,156 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace tests\functional\Tools\Command; + +use Glpi\Tests\GLPITestCase; +use Glpi\Tools\Command\LicenceHeadersCheckCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +class LicenceHeadersCheckCommandTest extends GLPITestCase +{ + private string $test_dir; + + public function setUp(): void + { + parent::setUp(); + $this->test_dir = sys_get_temp_dir() . '/glpi_test_header_' . uniqid(); + if (!mkdir($this->test_dir) && !is_dir($this->test_dir)) { + $this->markTestSkipped('Could not create temp directory'); + } + } + + public function tearDown(): void + { + $this->removeDirectory($this->test_dir); + parent::tearDown(); + } + + public function testMissingHeader(): void + { + file_put_contents($this->test_dir . '/no_header1.php', "test_dir . '/no_header2.php', "execute([ + '--directory' => $this->test_dir, + '--fix' => true, + ]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('[OK] Fixed 2 files without header.', $output); + } + + public function testOutdatedHeader(): void + { + $content = <<test_dir . '/outdated.php', $content); + + $command = new LicenceHeadersCheckCommand(); + $tester = new CommandTester($command); + $tester->execute([ + '--directory' => $this->test_dir, + '--fix' => true, + ]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('[OK] Fixed 1 file with outdated header.', $output); + } + + public function testMixedIssues(): void + { + file_put_contents($this->test_dir . '/no_header.php', "test_dir . '/outdated.php', $content); + + $command = new LicenceHeadersCheckCommand(); + $tester = new CommandTester($command); + $tester->execute([ + '--directory' => $this->test_dir, + '--fix' => true, + ]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('[OK] Fixed 1 file without header and 1 file with outdated header.', $output); + } + + public function testNoFixOption(): void + { + file_put_contents($this->test_dir . '/no_header.php', "execute([ + '--directory' => $this->test_dir, + ]); + + $output = $tester->getDisplay(); + $this->assertMatchesRegularExpression('/\[ERROR\] Found 1 file without header\. Use --fix option to fix these\s+files\./', $output); + $this->assertEquals(Command::FAILURE, $tester->getStatusCode()); + } + + public function testSuccess(): void + { + // Empty dir, should pass + $command = new LicenceHeadersCheckCommand(); + $tester = new CommandTester($command); + $tester->execute([ + '--directory' => $this->test_dir, + ]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('[OK] Files headers are valid.', $output); + $this->assertEquals(0, $tester->getStatusCode()); + } +} diff --git a/tests/functional/Tools/Command/LocalesExtractCommandTest.php b/tests/functional/Tools/Command/LocalesExtractCommandTest.php new file mode 100644 index 000000000000..241e5b3fa42f --- /dev/null +++ b/tests/functional/Tools/Command/LocalesExtractCommandTest.php @@ -0,0 +1,104 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace tests\functional\Tools\Command; + +use Glpi\Tests\GLPITestCase; +use Glpi\Tools\Command\LocalesExtractCommand; +use Symfony\Component\Console\Tester\CommandTester; + +class LocalesExtractCommandTest extends GLPITestCase +{ + private ?string $plugin_dir = null; + + + public function tearDown(): void + { + if ($this->plugin_dir !== null && is_dir($this->plugin_dir)) { + $this->removeDirectory($this->plugin_dir); + $this->plugin_dir = null; + } + parent::tearDown(); + } + + public function testPluginExtraction(): void + { + // Mimic plugin structure + $plugin_name = 'testlocales_' . uniqid(); + $plugin_name_uc = strtoupper($plugin_name); + $this->plugin_dir = GLPI_ROOT . '/plugins/' . $plugin_name; + + if (!mkdir($this->plugin_dir)) { + $this->markTestSkipped('Could not create temp plugin directory in ' . GLPI_ROOT . '/plugins'); + } + mkdir($this->plugin_dir . '/locales'); + + $setup_content = << 'Test', + 'version' => PLUGIN_{$plugin_name_uc}_VERSION, + 'author' => 'Teclib\'', + 'license' => 'GPLv3+', + 'homepage' => '', + 'requirements' => [ + 'glpi' => [ + 'min' => '11.0.0', + 'max' => '11.0.99', + ], + ], + ]; +} +EOF; + + file_put_contents($this->plugin_dir . '/setup.php', $setup_content); + file_put_contents($this->plugin_dir . '/test.php', "execute([ + '--plugin' => $plugin_name, + ]); + + $this->assertEquals(0, $tester->getStatusCode()); + $dest_pot = $this->plugin_dir . '/locales/' . $plugin_name . '.pot'; + $this->assertFileExists($dest_pot); + $content = file_get_contents($dest_pot); + $this->assertStringContainsString('msgid "My String"', $content); + } +} diff --git a/tests/src/GLPITestCase.php b/tests/src/GLPITestCase.php index 8386bd0fb25b..42e7b3e35f9c 100644 --- a/tests/src/GLPITestCase.php +++ b/tests/src/GLPITestCase.php @@ -200,22 +200,25 @@ protected function resetPictures() throw new RuntimeException('Invalid picture dir: ' . $dir); } // Delete nested folders and files in dir - $fn_delete = function ($dir, $parent) use (&$fn_delete) { - $files = glob($dir . '/*') ?? []; - foreach ($files as $file) { - if (is_dir($file)) { - $fn_delete($file, $parent); - } else { - unlink($file); - } - } - if ($dir !== $parent) { - rmdir($dir); + $this->removeDirectory($dir); + // We recreate the directory to ensure it's empty and present, as test rely on it being present. + mkdir($dir); + } + + protected function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + if (is_dir("$dir/$file")) { + $this->removeDirectory("$dir/$file"); + } else { + unlink("$dir/$file"); } - }; - if (file_exists($dir) && is_dir($dir)) { - $fn_delete($dir, $dir); } + rmdir($dir); } /** diff --git a/tools/build_glpi.sh b/tools/build_glpi.sh index 92adaaaea486..50b7db74cb3b 100755 --- a/tools/build_glpi.sh +++ b/tools/build_glpi.sh @@ -49,7 +49,7 @@ echo "Installing dependencies..." $WORKING_DIR/bin/console dependencies install --composer-options="--ignore-platform-reqs --prefer-dist --no-progress" echo "Compiling locale files..." -$WORKING_DIR/bin/console locales:compile +$WORKING_DIR/bin/console tools:locales:compile echo "Moving JS files into the /public directory..." for file in $(find $WORKING_DIR/js -type f ! -path "$WORKING_DIR/js/src/*") diff --git a/tools/src/Command/AbstractCommand.php b/tools/src/Command/AbstractCommand.php new file mode 100644 index 000000000000..80b9ae31c1fa --- /dev/null +++ b/tools/src/Command/AbstractCommand.php @@ -0,0 +1,121 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tools\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Command extending this class don't have a native way to interact with the GLPI core. + * If you need to interact with it or its database, use the `Glpi\Console\AbstractCommand` instead. + */ +abstract class AbstractCommand extends Command +{ + protected InputInterface $input; + protected OutputInterface $output; + protected SymfonyStyle $io; + + #[Override] + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->input = $input; + $this->output = $output; + $this->io = new SymfonyStyle($input, $output); + + if ($this->isRequiringPluginOption() && $input->getOption('plugin') === null) { + throw new InvalidOptionException('The "--plugin" option is required for this command.'); + } + } + + #[Override] + protected function configure(): void + { + parent::configure(); + + if ($this->isPluginOptionAvailable()) { + $this->addOption( + 'plugin', + 'p', + InputOption::VALUE_REQUIRED, + 'Plugin name' + ); + } + } + + public function isPluginCommand(): bool + { + return $this->input->hasOption('plugin') && $this->input->getOption('plugin') !== null; + } + + public function getPluginName(): string + { + if (!$this->isPluginCommand()) { + throw new \LogicException('This command is not a plugin command.'); + } + return $this->input->getOption('plugin'); + } + + protected function getPluginDirectory(): string + { + $plugin_name = $this->getPluginName(); + $directory = \Plugin::getPhpDir($plugin_name); + if (!$directory) { + throw new \RuntimeException(sprintf('Plugin directory for "%s" not found.', $plugin_name)); + } + return $directory; + } + + /** + * Declare whether the command supports --plugin. + * @return bool + */ + protected function isPluginOptionAvailable(): bool + { + return $this->isRequiringPluginOption(); + } + + /** + * Declare the command supporting plugin and require it to be set. + * @return bool + */ + protected function isRequiringPluginOption(): bool + { + return false; + } +} diff --git a/tools/src/Command/CheckTwigTemplatesSyntaxCommand.php b/tools/src/Command/CheckTwigTemplatesSyntaxCommand.php index c4918e15d476..89dfc1d2836b 100644 --- a/tools/src/Command/CheckTwigTemplatesSyntaxCommand.php +++ b/tools/src/Command/CheckTwigTemplatesSyntaxCommand.php @@ -48,7 +48,7 @@ final class CheckTwigTemplatesSyntaxCommand extends Command { /** - * Error code returned when some templates have invalid yntax. + * Error code returned when some templates have invalid syntax. * * @var int */ diff --git a/tools/src/Command/LicenceHeadersCheckCommand.php b/tools/src/Command/LicenceHeadersCheckCommand.php new file mode 100644 index 000000000000..d54ea2b3e253 --- /dev/null +++ b/tools/src/Command/LicenceHeadersCheckCommand.php @@ -0,0 +1,795 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tools\Command; + +use RecursiveDirectoryIterator; +use RecursiveFilterIterator; +use RecursiveIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +final class LicenceHeadersCheckCommand extends AbstractCommand +{ + /** + * Header lines. + * + * @var array|null + */ + private ?array $header_lines = null; + + #[Override] + protected function isPluginOptionAvailable(): bool + { + return true; + } + + #[Override] + protected function configure(): void + { + parent::configure(); + + $this->setName('tools:licence_headers_check'); + $this->setDescription('Check licence header in code source files.'); + + $this->addOption( + 'directory', + 'd', + InputOption::VALUE_OPTIONAL, + 'Directory to parse (optional)', + ); + + $this->addOption( + 'header-file', + null, + InputOption::VALUE_OPTIONAL, + 'Header file to use (optional)', + ); + + $this->addOption( + 'fix', + 'f', + InputOption::VALUE_NONE, + 'Fix missing and outdated headers' + ); + + $this->addOption( + 'discard-extra-tags', + null, + InputOption::VALUE_NONE, + 'Discard extra tags found in headers' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $project_dir = dirname(__DIR__, 3); // Root of GLPI + + if ($this->isPluginCommand()) { + $project_dir = $this->getPluginDirectory(); + } + + /** @var string|null $header_file_path */ + $header_file_path = $this->input->getOption('header-file'); + if (!$header_file_path) { + $path = implode(DIRECTORY_SEPARATOR, [$project_dir, '.licence-header']); + $legacy_path = implode(DIRECTORY_SEPARATOR, [$project_dir, 'tools', 'HEADER']); + if (file_exists($path)) { + $header_file_path = realpath($path); + } elseif (file_exists($legacy_path)) { + $header_file_path = realpath($legacy_path); + } + } + + if (!$header_file_path) { + throw new \RuntimeException('No header path defined.'); + } + + if ($this->io->isVerbose()) { + $this->io->info(sprintf('HEADER path: %s', $header_file_path)); + } + + $target_dir = $input->getOption('directory') ?? $project_dir; + $files = $this->getFilesToParse($target_dir); + + if ($this->io->isVerbose()) { + $this->io->info(sprintf('%s files to process in %s.', count($files), $target_dir)); + } + + $missing_found = 0; + $missing_errors = 0; + $outdated_found = 0; + $outdated_errors = 0; + + /** @var string $filename */ + foreach ($files as $filename) { + if ($this->io->isVerbose()) { + $this->io->text('' . sprintf('Processing "%s".', $filename) . ''); + } + + if (($file_lines = file($filename)) === false) { + throw new \Exception(sprintf('Unable to read file "%s".', $filename)); + } + + $header_start_pattern = null; + $header_end_pattern = null; + $header_content_pattern = null; + + $extension = pathinfo($filename, PATHINFO_EXTENSION); + if ($extension === '') { + // No extension, file is probably a binary. + // Try to compute extension from shebang. + $first_line = $file_lines[0]; + if (preg_match('/^#!/', $first_line)) { + $shebang_matches = []; + if ( + // `#!/usr/bin/env php [options]` format + preg_match('/^#!\/usr\/bin\/env\s+(?[^\s]+)(\s+.*)?$/', $first_line, $shebang_matches) + // `#!/bin/bash [options]` format + || preg_match('/^#!(.{0}|\/([^\/]+\/)*(?[^\/\s]+))(\s+.*)?$/', $first_line, $shebang_matches) + ) { + $binary = $shebang_matches['binary']; + switch ($shebang_matches['binary']) { + case 'bash': + $extension = 'sh'; + break; + case 'perl': + $extension = 'pl'; + break; + case 'php': + default: + $extension = $binary; + break; + } + } + } + } + switch ($extension) { + case 'pl': + case 'sh': + case 'yaml': + case 'yml': + $header_line_prefix = '# '; + $header_prepend_line = "#\n"; + $header_append_line = "#\n"; + $header_start_pattern = '/^#[^!]/'; // Any commented line except shebang (#!) + $header_content_pattern = '/^#/'; + break; + case 'sql': + $header_line_prefix = '-- '; + $header_prepend_line = "--\n"; + $header_append_line = "--\n"; + $header_content_pattern = '/^(--|#)/'; // older headers were prefixed by "#" + break; + case 'css': + case 'scss': + $header_line_prefix = ' * '; + $header_prepend_line = "/*!\n"; + $header_append_line = " */\n"; + $header_start_pattern = '/^\/\*(\!|\*)?$/'; // older headers were starting by "/**" or "/*!" + $header_end_pattern = '/\*\//'; + break; + case 'twig': + $header_line_prefix = ' # '; + $header_prepend_line = "{#\n"; + $header_append_line = " #}\n"; + $header_start_pattern = '/^\{#$/'; + $header_end_pattern = '/#}/'; + break; + default: + $header_line_prefix = ' * '; + $header_prepend_line = "/**\n"; + $header_append_line = " */\n"; + $header_start_pattern = '/^\/\*(\!|\*)?$/'; // accept "/*", "/**" and "/*!" + $header_end_pattern = '/\*\//'; + break; + } + + if ($header_start_pattern === null) { + // If there is no specific "start pattern", then first regular comment line is consider are header start. + $header_start_pattern = $header_content_pattern; + } + + $header_found = false; + $header_missing = false; + $is_header_line = false; + $is_last_header_line = false; + $pre_header_lines = []; + $current_header_lines = []; + $post_header_lines = []; + + foreach ($file_lines as $line) { + if (!$header_found && !$header_missing) { + if (preg_match($header_start_pattern, $line)) { + // Line matches header opening line + $header_found = true; + $is_header_line = true; + } elseif (!$this->shouldLineBeLocatedBeforeHeader($line)) { + // Line does not match allowed lines before header, + // consider that header is missing. + $header_missing = true; + } + } elseif ($is_last_header_line) { + // Previous line was "last header line", so current line is the first line after licence header + $is_last_header_line = false; + $is_header_line = false; + } elseif ($is_header_line && $header_end_pattern !== null && preg_match($header_end_pattern, $line)) { + // Line matches header end pattern + $is_last_header_line = true; + } elseif ($is_header_line && $header_content_pattern !== null && !preg_match($header_content_pattern, $line)) { + // Line does not match header, so it is the first line after licence header + $is_header_line = false; + } + + if ($header_missing || ($header_found && !$is_header_line)) { + $post_header_lines[] = $line; + } elseif ($is_header_line) { + $current_header_lines[] = $line; + } else { + $pre_header_lines[] = $line; + } + } + + $preserved_tagged_data = $this->input->getOption('discard-extra-tags') + ? [] + : $this->extractTaggedData($current_header_lines, $header_line_prefix); + + $updated_header_lines = $this->getLicenceHeaderLines( + $header_file_path, + $header_line_prefix, + $header_prepend_line, + $header_append_line, + $preserved_tagged_data + ); + + $sliced_header_lines = array_slice($updated_header_lines, 1, -1); + $header_outdated = $sliced_header_lines !== array_slice($current_header_lines, 1, -1); + + if (!$header_missing && !$header_outdated) { + continue; + } + + if ($header_missing) { + $this->io->writeln(sprintf('Missing licence header in file "%s".', $filename)); + $missing_found++; + } else { + $this->io->writeln(sprintf('Licence header outdated in file "%s".', $filename)); + $outdated_found++; + } + + if ($this->input->getOption('fix')) { + $pre_header_lines = $this->stripEmptyLines($pre_header_lines, false, true); + $post_header_lines = $this->stripEmptyLines($post_header_lines, true, false); + + $file_contents = ''; + if ($pre_header_lines !== []) { + $file_contents .= implode('', $pre_header_lines) . "\n"; + } + $file_contents .= implode('', $updated_header_lines); + if ($post_header_lines !== []) { + $file_contents .= "\n" . implode('', $post_header_lines); + } + + if (strlen($file_contents) !== file_put_contents($filename, $file_contents)) { + $this->io->error(sprintf('Unable to update licence header in file "%s".', $filename)); + if ($header_missing) { + $missing_errors++; + } else { + $outdated_errors++; + } + } + } + } + + if ($missing_found === 0 && $outdated_found === 0) { + $this->io->success('Files headers are valid.'); + return 0; // Success + } + + $build_msg = function (int $missing_count, int $outdated_count): string { + $messages = []; + if ($missing_count > 0) { + $messages[] = sprintf( + '%d file%s without header', + $missing_count, + $missing_count > 1 ? 's' : '' + ); + } + if ($outdated_count > 0) { + $messages[] = sprintf( + '%d file%s with outdated header', + $outdated_count, + $outdated_count > 1 ? 's' : '' + ); + } + return implode(' and ', $messages); + }; + + if (!$this->input->getOption('fix')) { + $msg = sprintf( + 'Found %s. Use --fix option to fix these files.', + $build_msg($missing_found, $outdated_found) + ); + $this->io->error($msg); + return self::FAILURE; + } + + $msg = sprintf( + 'Fixed %s.', + $build_msg( + $missing_found - $missing_errors, + $outdated_found - $outdated_errors + ) + ); + $this->io->success($msg); + + if ($missing_errors > 0 || $outdated_errors > 0) { + $this->io->error(sprintf('%s file(s) cannot be updated.', $missing_errors + $outdated_errors)); + return self::FAILURE; + } + + return 0; // Success + } + + /** + * Get licence header lines. + * + * @param string|null $header_file_path + * @param string $line_prefix + * @param string $prepend_line + * @param string $append_line + * @param array $extra_tagged_data + * + * @return array + */ + private function getLicenceHeaderLines( + string $header_file_path, + string $line_prefix, + string $prepend_line, + string $append_line, + array $extra_tagged_data = [] + ): array { + if ($this->header_lines === null) { + if (($lines = file($header_file_path)) === false) { + throw new \Exception('Unable to read header file.'); + } + $this->header_lines = $lines; + } + + $lines = []; + $lines[] = $prepend_line; + foreach ($this->header_lines as $line) { + $lines[] = (preg_match('/^\s+$/', $line) ? rtrim($line_prefix) : $line_prefix) . $line; + } + $lines[] = $append_line; + + $lines = $this->appendTaggedData($lines, $extra_tagged_data, $line_prefix); + + return $this->stripEmptyLines($lines, true, true); + } + + /** + * Return files to parse. + * + * @param string $directory + * + * @return array + */ + private function getFilesToParse(string $directory): array + { + $directory = realpath($directory); + + if (!is_dir($directory) || !is_readable($directory)) { + throw new InvalidOptionException( + sprintf('Unable to read directory "%s"', $directory) + ); + } + + $dir_iterator = new RecursiveDirectoryIterator($directory); + $exclusion_pattern = $this->getExclusionPattern($directory); + + $filter_iterator = new class ($dir_iterator, $exclusion_pattern) extends RecursiveFilterIterator { + private $exclusion_pattern; + + public function __construct(RecursiveIterator $iterator, ?string $exclusion_pattern) + { + $this->exclusion_pattern = $exclusion_pattern; + parent::__construct($iterator); + } + + public function accept(): bool + { + $file = $this->current(); + if ($this->exclusion_pattern !== null && preg_match($this->exclusion_pattern, $file->getRealPath())) { + return false; + } + if ($file->isDir()) { + return true; // parse subdirectories + } + if (preg_match('/^(css|js|ts|php|pl|scss|sh|sql|twig|ya?ml)$/', $file->getExtension())) { + return true; // handled extensions + } + if (basename($file->getPath()) === 'bin') { + return true; // executable + } + return false; + } + + public function getChildren(): ?RecursiveFilterIterator + { + /** @var RecursiveIterator */ + $inner = $this->getInnerIterator(); + return new self($inner->getChildren(), $this->exclusion_pattern); + } + }; + + $recursive_iterator = new RecursiveIteratorIterator( + $filter_iterator, + RecursiveIteratorIterator::SELF_FIRST + ); + + $files = []; + + /** @var SplFileInfo $file */ + foreach ($recursive_iterator as $file) { + if (!$file->isFile()) { + continue; + } + + $files[] = $file->getRealPath(); + } + + return $files; + } + + /** + * Indicates if a line can/should be located before licence header. + * + * @param string $line + * + * @return bool + */ + private function shouldLineBeLocatedBeforeHeader(string $line): bool + { + // PHP opening tag + if (rtrim($line) === 'getTagPattern($line_prefix); + + foreach ($lines as $line) { + $tag = null; + if (preg_match($tag_pattern, $line, $tag)) { + $tag_name = $tag['name']; + $tag_value = $tag['value']; + + if (!array_key_exists($tag_name, $tagged_data)) { + $tagged_data[$tag_name] = []; + } + $tagged_data[$tag_name][] = $tag_value; + } + } + + return $tagged_data; + } + + /** + * Append tagged data to header lines. + * + * @param array $lines + * @param array $data_to_append + * @param string|null $line_prefix + * + * @return array + */ + private function appendTaggedData(array $lines, array $data_to_append, ?string $line_prefix = null): array + { + + $existing_data = $this->extractTaggedData($lines, $line_prefix); + + if (count($existing_data) === 0) { + $existing_tag_lines_nums = []; + $append_line_num = count($lines); // There is no tag in given lines, append new tags to the end. + } else { + $data_to_append = array_merge_recursive($existing_data, $data_to_append); + $data_to_append = array_map('array_unique', $data_to_append); + ksort($data_to_append); + + $existing_tag_lines_nums = array_keys(preg_grep($this->getTagPattern($line_prefix), $lines)); + $append_line_num = $existing_tag_lines_nums[0]; + } + + // Deduplicate tagged data + if (is_array($data_to_append)) { + foreach ($data_to_append as $tag_name => $tag_values) { + if (preg_match('/^copy(right|left)$/', $tag_name) !== 1) { + continue; + } + $data_to_append[$tag_name] = $this->unduplicateCopyTag($tag_values); + } + } + + // Drop existing tag lines and re-append merged tagged data entirely + $result_lines = []; + foreach ($lines as $num => $line) { + if (!in_array($num, $existing_tag_lines_nums)) { + $result_lines[] = $line; // Line is ot a tag line, keep it. + } + if ($num === $append_line_num) { + // Append entire tag data + $pad = max(array_map('strlen', array_keys($data_to_append))); + foreach ($data_to_append as $tag_name => $tag_values) { + foreach ($tag_values as $tag_value) { + $result_lines[] = $line_prefix . sprintf('@%s %s', str_pad($tag_name, $pad), $tag_value) . "\n"; + } + } + } + } + + return $result_lines; + } + + /** + * Get regex pattern used to detect/extract tagged data. + * + * @param string $line_prefix + * + * @return string + */ + private function getTagPattern(?string $line_prefix = null): string + { + return '/^' + . ($line_prefix !== null ? '(?:' . preg_quote($line_prefix, '/') . ')?' : '') // may be prefixed by line prefix + . '\s*' // may be prefixed by whitespace + . '@(?[a-z]+)' // @tagname + . '\s+' // space between tag and value + . '(?.+)' // value + . '$/i'; + } + + /** + * Unduplicate copyright/copyleft tags values. + * + * @param array $values + * + * @return array + */ + private function unduplicateCopyTag(array $values): array + { + $copy_dates_pattern = '/^' + . '(?.+\s+)?' // capture everything before dates + . '(?\d{4})' // mandatory date (unique year or starting year) + . '(-(?\d{4}))?' // optionnal ending date with `-` separator + . '(?\s+.+)?' // capture everything after dates + . '$/'; + + $preserved_values = []; + + foreach ($values as $value) { + $dates_matches = []; + if (preg_match($copy_dates_pattern, $value, $dates_matches) !== 1) { + continue; + } + + $before = trim($dates_matches['before'] ?? ''); + $before_pattern = strlen($before) > 0 + ? '\s*' . preg_quote($before, '/') . '\s+' + : ''; + $after = trim($dates_matches['after'] ?? ''); + $after_pattern = strlen($after) > 0 + ? '\s+' . preg_quote($after, '/') . '\s*' + : ''; + + $similar_pattern = '/^' + . $before_pattern + . '(?\d{4})(-(?\d{4}))?' + . $after_pattern + . '$/'; + + if (count(preg_grep($similar_pattern, $preserved_values)) > 0) { + // similar value already computed + continue; + } + + $similar_values = preg_grep($similar_pattern, $values); + + if (count($similar_values) === 1) { + // found only current value, no need to deduplicate + $preserved_values[] = $value; + continue; + } + + // Compute min starting and max ending dates + $starting_date = $dates_matches['starting_date']; + $ending_date = !empty($dates_matches['ending_date']) ? $dates_matches['ending_date'] : $starting_date; + foreach ($similar_values as $similar_value) { + $similar_dates_matches = []; + preg_match($copy_dates_pattern, $similar_value, $similar_dates_matches); + if ($similar_dates_matches['starting_date'] < $starting_date) { + $starting_date = $similar_dates_matches['starting_date']; + } elseif ($similar_dates_matches['starting_date'] > $ending_date) { + $ending_date = $similar_dates_matches['starting_date']; + } + if (!empty($similar_dates_matches['ending_date']) && $similar_dates_matches['ending_date'] > $ending_date) { + $ending_date = $similar_dates_matches['ending_date']; + } + } + $preserved_values[] = ($dates_matches['before'] ?? '') + . $starting_date + . ($ending_date !== $starting_date ? '-' . $ending_date : '') + . ($dates_matches['after'] ?? ''); + } + + return $preserved_values; + } + + /** + * Get files exclusion pattern. All files matching this pattern will be excluded from checks. + * + * @param string $directory + * + * @return string + */ + protected function getExclusionPattern(string $directory): ?string + { + $excluded_elements = [ + '(\.|.*\/\.).+', // Any hidden file/directory + + '(docker-)?compose(\.override)?\.ya?ml', // docker compose configuration files + + 'node_modules', // npm imported libs + 'vendor', // composer imported libs + + 'public\/lib', // libs packaged using webpack + ]; + if (file_exists($directory . DIRECTORY_SEPARATOR . 'setup.php') + && file_exists($directory . DIRECTORY_SEPARATOR . 'hook.php')) { + // Directory is a plugin root directory + $excluded_elements = array_merge( + $excluded_elements, + [ + 'lib', // Manually included libs + 'dist', // Plugin archives + 'var', // Lint cache + ] + ); + } elseif (file_exists($directory . DIRECTORY_SEPARATOR . 'composer.json') + && preg_match('/"name"\s*:\s*"glpi\/glpi"/', file_get_contents($directory . DIRECTORY_SEPARATOR . 'composer.json'))) { + // Directory is GLPI root directory + $excluded_elements = array_merge( + $excluded_elements, + [ + 'config', + 'css\/lib', + 'lib\/(?!(bundles|index\.php)).+', // Manually included libs, but do not exclude "bundles" subdir or "index.php" + 'files', + 'marketplace', + 'plugins', + 'tests\/config', + 'tests\/config_db\.php', + 'tests\/files', + 'tests\/e2e\/glpi_config', + 'tests\/e2e\/glpi_files', + ] + ); + } + + if ($excluded_elements === []) { + return null; + } + + return '/^' + . preg_quote($directory . DIRECTORY_SEPARATOR, '/') + . '(' . implode('|', $excluded_elements) . ')' + . '$/'; + } +} diff --git a/tools/src/Command/LocalesCompileCommand.php b/tools/src/Command/LocalesCompileCommand.php new file mode 100644 index 000000000000..73daf0386cf9 --- /dev/null +++ b/tools/src/Command/LocalesCompileCommand.php @@ -0,0 +1,122 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tools\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Process; + +final class LocalesCompileCommand extends AbstractCommand +{ + #[Override] + protected function isPluginOptionAvailable(): bool + { + return true; + } + + protected function configure(): void + { + parent::configure(); + $this->setName('tools:locales:compile'); + $this->setDescription('Compile MO files from PO files.'); + $this->addOption( + 'directory', + 'd', + InputOption::VALUE_REQUIRED, + 'Source directory containing the locales folder with PO files.' + ); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $directory = $input->getOption('directory'); + if ($directory !== null) { + $working_dir = $directory; + } elseif ($this->isPluginCommand()) { + $working_dir = $this->getPluginDirectory(); + } else { + $working_dir = dirname(__DIR__, 3); // glpi + } + + $success = $this->compile($working_dir); + return $success ? Command::SUCCESS : Command::FAILURE; + } + + private function compile(string $dir): bool + { + $locales_dir = $dir . '/locales'; + $this->io->section("Compiling MO files..."); + $this->io->writeln(" Locales dir: $locales_dir", OutputInterface::VERBOSITY_VERBOSE); + + if (!is_dir($locales_dir)) { + $this->io->error("Locales dir '$locales_dir' does not exist."); + return false; + } + + $files = glob($locales_dir . '/*.po'); + if (empty($files)) { + $this->io->error("No .po files found in $locales_dir"); + return false; + } + + // Check msgfmt + $finder = new ExecutableFinder(); + if (!$finder->find('msgfmt')) { + $this->io->error("msgfmt executable not found!"); + return false; + } + + $success = true; + foreach ($files as $file) { + $mo = preg_replace('/\.po$/', '.mo', $file); + $basename = basename($file); + + $proc = new Process(['msgfmt', $file, '-o', $mo]); + $proc->run(); + + if (!$proc->isSuccessful()) { + $success = false; + $this->io->writeln(" Failed to compile $basename"); + } else { + $this->io->writeln(" Compiled $basename"); + } + } + return $success; + } +} diff --git a/tools/src/Command/LocalesExtractCommand.php b/tools/src/Command/LocalesExtractCommand.php new file mode 100644 index 000000000000..5011ea239eee --- /dev/null +++ b/tools/src/Command/LocalesExtractCommand.php @@ -0,0 +1,493 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tools\Command; + +use RecursiveDirectoryIterator; +use RecursiveFilterIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Process; +use Twig\Cache\CacheInterface; +use Twig\Cache\FilesystemCache; +use Twig\Environment; +use Twig\Loader\FilesystemLoader; +use Twig\Loader\LoaderInterface; +use Twig\TwigFilter; +use Twig\TwigFunction; +use Twig\TwigTest; + +final class LocalesExtractCommand extends AbstractCommand +{ + #[Override] + protected function isPluginOptionAvailable(): bool + { + return true; + } + + protected function configure(): void + { + parent::configure(); + $this->setName('tools:locales:extract'); + $this->setDescription('Extract strings from the project to generate POT file.'); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + ini_set('memory_limit', -1); // This is required due to high memory usage when extracting for core. + + if ($this->isPluginCommand()) { + $working_dir = $this->getPluginDirectory(); + } else { + $working_dir = dirname(__DIR__, 3); // glpi + } + + $finder = new ExecutableFinder(); + if (!$finder->find('xgettext')) { + $this->io->error('xgettext not found. Please install gettext.'); + return Command::FAILURE; + } + + // Define translate function args + $args = [ + 'F_ARGS_N' => '1,2', + 'F_ARGS__S' => '1', + 'F_ARGS__' => '1', + 'F_ARGS_X' => '1c,2', + 'F_ARGS_SX' => '1c,2', + 'F_ARGS_NX' => '1c,2,3', + 'F_ARGS_SN' => '1,2', + ]; + + // Compute POT filename + if ($this->isPluginCommand()) { + $name = $this->getPluginName(); + $exclude_regex = '/^\.\/(\..*|(libs?|node_modules|tests|vendor)\/).*/'; + + // Only strings with domain specified are extracted + $args['F_ARGS_N'] .= ',4t'; + $args['F_ARGS__S'] .= ',2t'; + $args['F_ARGS__'] .= ',2t'; + $args['F_ARGS_X'] .= ',3t'; + $args['F_ARGS_SX'] .= ',3t'; + $args['F_ARGS_NX'] .= ',5t'; + $args['F_ARGS_SN'] .= ',4t'; + } else { + // core + $name = 'GLPI'; + $exclude_regex = '/^\.\/(\..*|(config|files|lib|marketplace|node_modules|plugins|public|tests|tools|vendor)\/).*/'; + } + + $potfile = $working_dir . '/locales/' . strtolower($name) . '.pot'; + + if (!is_dir($working_dir . '/locales') && !mkdir($working_dir . '/locales')) { + $this->io->error(sprintf('Unable to create the `%s/locales` directory.', $working_dir)); + return Command::FAILURE; + } + + // Clean existing POT file + if (file_exists($potfile) && (!unlink($potfile))) { + $this->io->error(sprintf('Unable to override the `%s` file.', $potfile)); + return Command::FAILURE; + } + + if (!touch($potfile)) { + $this->io->error(sprintf('Unable to create the `%s` file.', $potfile)); + return Command::FAILURE; + } + + // Append locales from Twig templates + if (is_dir($working_dir . '/templates')) { + $this->io->section('Processing Twig templates...'); + $temp_twig_dir = sys_get_temp_dir() . '/glpi-locales-' . uniqid(); + if (!mkdir($temp_twig_dir . '/templates', 0o777, true)) { + $this->io->error(sprintf('Unable to create the `%s/templates` dir.', $temp_twig_dir)); + return Command::FAILURE; + } + + $this->io->writeln("Compiling twig templates into php files..."); + $root_path = $this->isPluginCommand() ? $this->getPluginDirectory() : dirname($working_dir . '/templates'); + $this->compileTwigTemplates( + $working_dir . '/templates', + $temp_twig_dir . '/templates', + $root_path + ); + + $this->io->writeln("Extracting translations from files"); + $twig_files = $this->getFiles($temp_twig_dir, 'twig'); + if (count($twig_files) > 0) { + // Write files list to usage in xgettext via -f + if ( + ($list_file = tempnam(sys_get_temp_dir(), 'twigfiles')) === false + || file_put_contents($list_file, implode("\n", $twig_files)) === false + ) { + $this->io->error('Unable to create the Twig files list file.'); + return Command::FAILURE; + } + $command = array_merge( + [ + 'xgettext', + '--files-from=' . $list_file, + '-o', $potfile, + '-L', 'PHP', + '--add-comments=TRANS', + '--add-location=file', + '--from-code=UTF-8', + '--force-po', + '--join-existing', + '--keyword=_n:' . $args['F_ARGS_N'], + '--keyword=__:' . $args['F_ARGS__'], + '--keyword=_x:' . $args['F_ARGS_X'], + '--keyword=_nx:' . $args['F_ARGS_NX'], + ], + $twig_files + ); + $this->runCommand($command, $temp_twig_dir); + } + + // Cleanup + $this->runCommand(['rm', '-rf', $temp_twig_dir]); + } + + // Append locales from PHP + $this->io->section('Processing PHP files...'); + $php_files = $this->getFiles($working_dir, 'php', $exclude_regex); + if (count($php_files) > 0) { + // Write files list to usage in xgettext via -f + if ( + ($list_file = tempnam(sys_get_temp_dir(), 'phpfiles')) === false + || file_put_contents($list_file, implode("\n", $php_files)) === false + ) { + $this->io->error('Unable to create the PHP files list file.'); + return Command::FAILURE; + } + + $this->runCommand( + [ + 'xgettext', + '--files-from=' . $list_file, + '-o', $potfile, + '-L', 'PHP', + '--add-comments=TRANS', + '--from-code=UTF-8', + '--force-po', + '--join-existing', + '--keyword=_n:' . $args['F_ARGS_N'], + '--keyword=__s:' . $args['F_ARGS__S'], + '--keyword=__:' . $args['F_ARGS__'], + '--keyword=_x:' . $args['F_ARGS_X'], + '--keyword=_sx:' . $args['F_ARGS_SX'], + '--keyword=_nx:' . $args['F_ARGS_NX'], + '--keyword=_sn:' . $args['F_ARGS_SN'], + ], + $working_dir + ); + unlink($list_file); + } + + // Append locales from JS + $this->io->section('Processing JS files...'); + $js_files = $this->getFiles($working_dir, 'js', $exclude_regex); + // Exclude min.js + $js_files = array_filter($js_files, fn($f) => !str_ends_with($f, '.min.js')); + + if (count($js_files) > 0) { + if ( + ($list_file = tempnam(sys_get_temp_dir(), 'jsfiles')) === false + || file_put_contents($list_file, implode("\n", $js_files)) === false + ) { + $this->io->error('Unable to create the JS files list file.'); + return Command::FAILURE; + } + + $this->runCommand( + [ + 'xgettext', + '--files-from=' . $list_file, + '-o', $potfile, + '-L', 'JavaScript', + '--add-comments=TRANS', + '--from-code=UTF-8', + '--force-po', + '--join-existing', + '--keyword=_n:' . $args['F_ARGS_N'], + '--keyword=__:' . $args['F_ARGS__'], + '--keyword=_x:' . $args['F_ARGS_X'], + '--keyword=_nx:' . $args['F_ARGS_NX'], + '--keyword=i18n._n:' . $args['F_ARGS_N'], + '--keyword=i18n.__:' . $args['F_ARGS__'], + '--keyword=i18n._p:' . $args['F_ARGS_X'], + '--keyword=i18n.ngettext:' . $args['F_ARGS_N'], + '--keyword=i18n.gettext:' . $args['F_ARGS__'], + '--keyword=i18n.pgettext:' . $args['F_ARGS_X'], + ], + $working_dir + ); + unlink($list_file); + } + + // Append locales from Vue + $this->io->section('Processing Vue files...'); + $vue_files = $this->getFiles($working_dir, 'vue', $exclude_regex); + if (count($vue_files) > 0) { + // Run extraction using local npm script + $this->runCommand(['npm', 'run', 'vue:gettext:extract'], $working_dir); + + $vue_pot = $working_dir . '/locales/vue.pot'; + if (file_exists($vue_pot)) { + $this->io->writeln("Merge vue locales."); + // merge vue pot with the existing global pot file + $this->runCommand([ + 'xgettext', + '-o', $potfile, + '--join-existing', + $vue_pot, + ]); + unlink($vue_pot); + } else { + $this->io->error('No vue locales generated to merge.'); + return Command::FAILURE; + } + } + + // Update main language + $this->io->section('Updating en_GB.po...'); + $this->runCommand( + [ + 'msginit', + '--no-translator', + '-i', $potfile, + '-l', 'en_GB', + '-o', $working_dir . '/locales/en_GB.po', + ], + // Environment variables for this command (LANG=C) + env: ['LANG' => 'C'] + ); + + $this->io->success('Locales extracted successfully.'); + return Command::SUCCESS; + } + + /** + * @return string[] + */ + private function getFiles($directory, $extension, $exclude_regex = null): array + { + $dir_iterator = new RecursiveDirectoryIterator($directory); + $iterator = new RecursiveIteratorIterator($dir_iterator); + + $filter = new \CallbackFilterIterator($iterator, function ($file) use ($directory, $extension, $exclude_regex) { + /** @var SplFileInfo $file */ + if (!$file->isFile() || $file->getExtension() !== $extension) { + return false; + } + + if ($exclude_regex) { + $path = $file->getPathname(); + $rel_path = './' . ltrim(substr($path, strlen($directory)), '/'); + if (preg_match($exclude_regex, $rel_path)) { + return false; + } + } + + return true; + }); + // Count total matching files first for progress bar + $total_files = iterator_count($filter); + $filter->rewind(); // Reset iterator + + $progress_bar = $this->io->createProgressBar($total_files); + $files = []; + foreach ($filter as $file) { + $path = $file->getPathname(); + $rel_path = './' . ltrim(substr($path, strlen($directory)), '/'); + $files[] = $rel_path; + $progress_bar->advance(); + } + $progress_bar->finish(); + $this->io->newLine(2); // Clean line break after progress bar + + return $files; + } + + private function runCommand(array $command, ?string $cwd = null, array $env = []): Process + { + $process = new Process($command, $cwd, $env); + $process->setTimeout(null); + + $callback = function ($type, $buffer) { + $this->output->write($buffer); + }; + + $process->mustRun($callback); + + return $process; + } + + /** + * Compile Twig templates into PHP files for locale extraction. + */ + private function compileTwigTemplates(string $templates_dir, string $output_dir, string $root_path): void + { + $loader = new FilesystemLoader($templates_dir, $root_path); + $twig = $this->getMockedTwigEnvironment($loader); + $twig->setCache($this->getTwigCacheHandler($output_dir)); + + $files = $this->getTwigTemplateFiles($templates_dir); + + $progress_bar = $this->io->createProgressBar(count($files)); + foreach ($files as $file) { + $twig->load($file); + $progress_bar->advance(); + } + $progress_bar->finish(); + + $this->io->newLine(2); + } + + /** + * Return template files from a directory. + * + * @return string[] + */ + private function getTwigTemplateFiles(string $directory): array + { + $directory = realpath($directory); + + if (!is_dir($directory) || !is_readable($directory)) { + throw new \RuntimeException( + sprintf('Unable to read directory "%s"', $directory) + ); + } + + $dir_iterator = new RecursiveDirectoryIterator($directory); + + $filter_iterator = new class ($dir_iterator) extends RecursiveFilterIterator { + public function accept(): bool + { + /** @var SplFileInfo $this */ + if ($this->isFile() && !preg_match('/^twig$/', $this->getExtension())) { + return false; + } + return true; + } + }; + + $recursive_iterator = new RecursiveIteratorIterator( + $filter_iterator, + RecursiveIteratorIterator::SELF_FIRST + ); + + $files = []; + + /** @var SplFileInfo $file */ + foreach ($recursive_iterator as $file) { + if (!$file->isFile()) { + continue; + } + + $files[] = preg_replace( + '/^' . preg_quote($directory . DIRECTORY_SEPARATOR, '/') . '/', + '', + $file->getRealPath() + ); + } + + return $files; + } + + /** + * Return a mocked Twig environment. + * This mocked environment will prevent exceptions to be thrown when custom + * functions, filters or tests are used in templates. + */ + private function getMockedTwigEnvironment(LoaderInterface $loader): Environment + { + return new class ($loader) extends Environment { + public function getFunction(string $name): ?TwigFunction + { + if (in_array($name, ['__', '_n', '_x', '_nx'], true)) { + // Return a function that has its own name as callback + // for translation functions, so Twig will generate code following this pattern: + // $name($parameter, ...)`, e.g. `__('str')` or `_n('str', 'strs', 5)`. + return new TwigFunction($name, $name); + } + return parent::getFunction($name) ?? new TwigFunction($name, function () {}); + } + + public function getFilter(string $name): ?TwigFilter + { + return parent::getFilter($name) ?? new TwigFilter($name, function () {}); + } + + public function getTest(string $name): ?TwigTest + { + if (in_array($name, ['divisible', 'same'])) { + // `same as` and `divisible by` will be search in 2 times. + // First check will be done on first word, should return `null` to + // trigger second search that will be done on full name. + return null; + } + return parent::getTest($name) ?? new TwigTest($name, function () {}); + } + }; + } + + /** + * Return a custom Twig cache handler. + * This handler is useful to be able to preserve filenames of compiled files. + */ + private function getTwigCacheHandler(string $directory): CacheInterface + { + return new class ($directory) extends FilesystemCache { + private string $directory; + + public function __construct(string $directory, int $options = 0) + { + $this->directory = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + parent::__construct($directory, $options); + } + + public function generateKey(string $name, string $className): string + { + return $this->directory . $name; + } + }; + } +} diff --git a/tools/src/Plugin/Command/AbstractPluginCommand.php b/tools/src/Plugin/Command/AbstractPluginCommand.php new file mode 100644 index 000000000000..bded25ed5da2 --- /dev/null +++ b/tools/src/Plugin/Command/AbstractPluginCommand.php @@ -0,0 +1,74 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tools\Plugin\Command; + +use Glpi\Tools\Command\AbstractCommand; +use Override; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Command extending this class don't have a native way to interact with the GLPI core. + * If you need to interact with it or its database, use the `Glpi\Console\AbstractCommand` instead. + */ +abstract class AbstractPluginCommand extends AbstractCommand +{ + #[Override] + protected function isRequiringPluginOption(): bool + { + return true; + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + parent::initialize($input, $output); + + $plugin_name = $this->input->getOption('plugin'); + if (!$plugin_name) { + throw new InvalidOptionException('The "--plugin" option is required.'); + } + + $root_dir = dirname(__DIR__, 4); + $plugin_dir = $root_dir . '/plugins/' . $plugin_name; + + if (!is_dir($plugin_dir)) { + throw new RuntimeException( + sprintf('Plugin directory "%s" not found.', $plugin_dir) + ); + } + } +} diff --git a/tools/src/Plugin/Command/PluginReleaseCommand.php b/tools/src/Plugin/Command/PluginReleaseCommand.php new file mode 100644 index 000000000000..1310ba5034a5 --- /dev/null +++ b/tools/src/Plugin/Command/PluginReleaseCommand.php @@ -0,0 +1,322 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tools\Plugin\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Process\Process; + +final class PluginReleaseCommand extends AbstractPluginCommand +{ + private string $dist_dir; + private string $plugin_name = ''; + private string $commit = ''; + + private const BANNED_FILES = [ + '.git*', + '.gh_token', + '.tx/', + 'tools/', + 'tests/', + '.atoum.php', + '.travis.yml', + '.circleci/', + '.ignore-release', + '.stylelintrc.js', + '.twig_cs.dist.php', + 'rector.php', + 'phpstan.neon', + '.phpcs.xml', + 'phpunit.xml', + 'phpunit.xml.dist', + ]; + + #[Override] + protected function configure(): void + { + parent::configure(); + $this->setName('tools:plugin:release'); + $this->setDescription('Build a GLPI plugin release archive.'); + + $this->addOption('ref', 'r', InputOption::VALUE_REQUIRED, 'Git ref to build', 'HEAD'); + $this->addOption('archive-name', 'a', InputOption::VALUE_REQUIRED, 'Release archive name, default to the Git ref name'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force rebuild even if release exists'); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $plugin_dir = $this->getPluginDirectory(); + + if (!file_exists($plugin_dir . '/setup.php')) { + $this->io->error('Current directory is not a valid GLPI plugin.'); + return Command::FAILURE; + } + + $this->dist_dir = $plugin_dir . '/dist'; + if (!is_dir($this->dist_dir) && !mkdir($this->dist_dir, 0o777, true)) { + $this->io->error(sprintf('Unable to create the `%s` directory.', $this->dist_dir)); + return Command::FAILURE; + } + + $this->plugin_name = $this->getPluginName(); + + $ref = $input->getOption('ref'); + $archive_name = $input->getOption('archive-name') ?: $ref; + + $tarball = $this->dist_dir . DIRECTORY_SEPARATOR . "glpi-{$this->plugin_name}-{$archive_name}.tar.bz2"; + + if (!$input->getOption('force') && file_exists($tarball)) { + $this->io->warning("Archive $tarball already exists."); + if (!$this->io->confirm('Do you want to rebuild it?', false)) { + return Command::FAILURE; + } + } + + return $this->build($ref, $tarball); + } + + private function build(string $ref, string $dest): int + { + $this->io->title("Releasing plugin {$this->plugin_name}@{$ref}..."); + + $plugin_dir = $this->getPluginDirectory(); + + // git ls-tree + $process = new Process(['git', 'ls-tree', '-r', $ref, '--name-only'], $plugin_dir); + $process->mustRun(); + $files = explode("\n", trim($process->getOutput())); + + // Filter banned + $banned = self::BANNED_FILES; + $ignore_release_file = $plugin_dir . '/.ignore-release'; + if (file_exists($ignore_release_file)) { + $lines = file($ignore_release_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $banned = array_merge($banned, $lines); + } + + $valid_files = []; + foreach ($files as $file) { + if (empty($file)) { + continue; + } + + $excluded = false; + foreach ($banned as $ban) { + if (fnmatch($ban, $file) || fnmatch($ban, basename($file)) || preg_match('#^' . preg_quote($ban, '#') . '#', $file)) { + $excluded = true; + break; + } + } + if (!$excluded) { + $valid_files[] = $file; + } + } + + // Git archive + $temp_tar = $this->dist_dir . '/temp.tar'; + $cmd = ['git', 'archive', '--prefix=' . $this->plugin_name . '/', '--output=' . $temp_tar, $ref]; + foreach ($valid_files as $f) { + $cmd[] = $f; + } + + $this->io->text("Archiving GIT ref {$ref}..."); + + $process = new Process($cmd, $plugin_dir); + $process->setTimeout(600); + $process->mustRun(function (string $type, string $buffer): void { + $this->output->write($buffer); + }); + + // Now we need to prepare (extract, add vendors, re-compress) + $src_dir = $this->dist_dir . '/src'; + $src_subdir = $src_dir . '/' . $this->plugin_name; + $fs = new Filesystem(); + if (is_dir($src_dir)) { + $fs->remove($src_dir); + } + if (!mkdir($src_dir)) { + $this->io->error(sprintf('Unable to create the `%s` directory.', $src_dir)); + return Command::FAILURE; + } + + $untar = new Process(['tar', '-xf', $temp_tar, '-C', $src_dir]); + $untar->mustRun(function (string $type, string $buffer): void { + $this->output->write($buffer); + }); + + if (!unlink($temp_tar)) { + $this->io->error(sprintf('Unable to delete the `%s` file.', $temp_tar)); + return Command::FAILURE; + } + + // Composer + if (file_exists($src_subdir . '/composer.json')) { + $this->io->section("Installing composer dependencies..."); + $c_cmd = ['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction']; + + $proc = new Process($c_cmd, $src_subdir); + $proc->setTimeout(300); + $proc->mustRun(function (string $type, string $buffer): void { + $this->output->write($buffer); + }); + + // Cleanup vendors + $this->cleanupVendor($src_subdir . '/vendor'); + + // Dump autoload + $this->io->newLine(); + $this->io->section("Dumping composer autoload..."); + $proc = new Process(['composer', 'dump-autoload', '-o', '--no-dev'], $src_subdir); + $proc->mustRun(function (string $type, string $buffer): void { + $this->output->write($buffer); + }); + + // Remove composer.lock + if (file_exists($src_subdir . '/composer.lock')) { + unlink($src_subdir . '/composer.lock'); + } + $this->io->writeln("Composer dependencies installed."); + } + + // NPM + if (file_exists($src_subdir . '/package.json')) { + $this->io->section("Installing npm dependencies..."); + $n_cmd = ['npm', 'install']; + $proc = new Process($n_cmd, $src_subdir); + $proc->setTimeout(600); + $proc->mustRun(function (string $type, string $buffer): void { + $this->output->write($buffer); + }); + + // Remove node_modules (assume npm install triggers postinstall build) + $fs->remove($src_subdir . '/node_modules'); + + // Remove package-lock.json + if (file_exists($src_subdir . '/package-lock.json')) { + unlink($src_subdir . '/package-lock.json'); + } + $this->io->writeln("Npm dependencies installed."); + } + + // Compile locales + if (is_dir($src_subdir . '/locales')) { + $input = new ArrayInput([ + 'command' => 'tools:locales:compile', + '--directory' => $src_subdir, + ]); + $input->setInteractive(false); + $this->getApplication()->doRun($input, $this->output); + $this->io->writeln("Locales compiled."); + } + + // Compress to bz2 + $this->io->section("Generating the archive"); + $this->io->writeln("Target: $dest", OutputInterface::VERBOSITY_VERBOSE); + + $tar_cmd = [ + 'tar', + '--format=ustar', + '-cjf', + $dest, + $this->plugin_name, + ]; + $proc = new Process($tar_cmd, $src_dir); + $proc->mustRun(function (string $type, string $buffer): void { + $this->output->write($buffer); + }); + + // Cleanup src + $fs->remove($src_dir); + + $this->io->success("Archive built: $dest"); + return Command::SUCCESS; + } + + private function cleanupVendor(string $vendor_dir): void + { + if (!is_dir($vendor_dir)) { + return; + } + + $fs = new Filesystem(); + $finder = new Finder(); + + // Remove git directories + $finder->directories()->in($vendor_dir)->name('.git*')->ignoreVCS(false); + foreach (iterator_to_array($finder->getIterator()) as $dir) { + $fs->remove($dir->getPathname()); + } + + // Remove test directories + $finder = new Finder(); + $finder->directories()->in($vendor_dir)->name('test')->name('tests'); + foreach (iterator_to_array($finder->getIterator()) as $dir) { + if (is_dir($dir->getPathname())) { + $fs->remove($dir->getPathname()); + } + } + + // Remove example directories + $finder = new Finder(); + $finder->directories()->in($vendor_dir)->name('example')->name('examples'); + foreach (iterator_to_array($finder->getIterator()) as $dir) { + if (is_dir($dir->getPathname())) { + $fs->remove($dir->getPathname()); + } + } + + // Remove doc directories + $finder = new Finder(); + $finder->directories()->in($vendor_dir)->name('doc')->name('docs'); + foreach (iterator_to_array($finder->getIterator()) as $dir) { + if (is_dir($dir->getPathname())) { + $fs->remove($dir->getPathname()); + } + } + + // Remove composer files in vendor subdirectories + $finder = new Finder(); + $finder->files()->in($vendor_dir)->name('composer.*')->depth('> 0'); + foreach (iterator_to_array($finder->getIterator()) as $file) { + $fs->remove($file->getPathname()); + } + } +} From 6adefde5479e1c1198aeff5e2cd90ea6f7600f9c Mon Sep 17 00:00:00 2001 From: Benoit VIGNAL Date: Tue, 3 Feb 2026 15:47:11 +0100 Subject: [PATCH 2/7] Lint --- tools/src/Command/LicenceHeadersCheckCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/src/Command/LicenceHeadersCheckCommand.php b/tools/src/Command/LicenceHeadersCheckCommand.php index d54ea2b3e253..4cc197969ab3 100644 --- a/tools/src/Command/LicenceHeadersCheckCommand.php +++ b/tools/src/Command/LicenceHeadersCheckCommand.php @@ -681,11 +681,11 @@ private function unduplicateCopyTag(array $values): array } $before = trim($dates_matches['before'] ?? ''); - $before_pattern = strlen($before) > 0 + $before_pattern = $before !== '' ? '\s*' . preg_quote($before, '/') . '\s+' : ''; $after = trim($dates_matches['after'] ?? ''); - $after_pattern = strlen($after) > 0 + $after_pattern = $after !== '' ? '\s+' . preg_quote($after, '/') . '\s*' : ''; From f6cd81fa0c408c67dd4440160a4f0d7d1b377045 Mon Sep 17 00:00:00 2001 From: Benoit VIGNAL Date: Tue, 3 Feb 2026 15:51:23 +0100 Subject: [PATCH 3/7] feat: switch to --dest --- PluginsMakefile.mk | 5 ++- .../Plugin/Command/PluginReleaseCommand.php | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/PluginsMakefile.mk b/PluginsMakefile.mk index e3e63f346fe6..e86285237514 100644 --- a/PluginsMakefile.mk +++ b/PluginsMakefile.mk @@ -85,9 +85,8 @@ locales-compile: ## Compile locales @$(CONSOLE) tools:locales:compile --plugin=$(PLUGIN_DIR) .PHONY: locales-compile -plugin-release: ## Build and create plugin release based on HEAD ref, example: make plugin-release n='1.0.12' - @$(eval n ?=) - @$(CONSOLE) tools:plugin:release --plugin=$(PLUGIN_DIR) --archive-name=$(n) +plugin-release: ## Build and create plugin release based on HEAD ref, example: make plugin-release + @$(CONSOLE) tools:plugin:release --plugin=$(PLUGIN_DIR) --dest=dist/glpi-$(PLUGIN_DIR)-$(shell date +%Y%m%d).tar.bz2 .PHONY: plugin-release diff --git a/tools/src/Plugin/Command/PluginReleaseCommand.php b/tools/src/Plugin/Command/PluginReleaseCommand.php index 1310ba5034a5..fdc2b1fc34d3 100644 --- a/tools/src/Plugin/Command/PluginReleaseCommand.php +++ b/tools/src/Plugin/Command/PluginReleaseCommand.php @@ -76,7 +76,7 @@ protected function configure(): void $this->setDescription('Build a GLPI plugin release archive.'); $this->addOption('ref', 'r', InputOption::VALUE_REQUIRED, 'Git ref to build', 'HEAD'); - $this->addOption('archive-name', 'a', InputOption::VALUE_REQUIRED, 'Release archive name, default to the Git ref name'); + $this->addOption('dest', 'd', InputOption::VALUE_REQUIRED, 'Destination path for the archive (e.g., /build/glpi-myplugin-1.0.0.tar.bz2)'); $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force rebuild even if release exists'); } @@ -90,27 +90,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $this->dist_dir = $plugin_dir . '/dist'; - if (!is_dir($this->dist_dir) && !mkdir($this->dist_dir, 0o777, true)) { - $this->io->error(sprintf('Unable to create the `%s` directory.', $this->dist_dir)); + $this->plugin_name = $this->getPluginName(); + + $dest = $input->getOption('dest'); + if ($dest === null) { + $this->io->error('The --dest option is required.'); return Command::FAILURE; } - $this->plugin_name = $this->getPluginName(); - - $ref = $input->getOption('ref'); - $archive_name = $input->getOption('archive-name') ?: $ref; + // Resolve relative paths based on plugin directory + if (!str_starts_with($dest, '/')) { + $dest = $plugin_dir . '/' . $dest; + } - $tarball = $this->dist_dir . DIRECTORY_SEPARATOR . "glpi-{$this->plugin_name}-{$archive_name}.tar.bz2"; + // Ensure parent directory exists + $this->dist_dir = dirname($dest); + if (!is_dir($this->dist_dir) && !mkdir($this->dist_dir, 0o777, true)) { + $this->io->error(sprintf('Unable to create the `%s` directory.', $this->dist_dir)); + return Command::FAILURE; + } - if (!$input->getOption('force') && file_exists($tarball)) { - $this->io->warning("Archive $tarball already exists."); + if (!$input->getOption('force') && file_exists($dest)) { + $this->io->warning("Archive $dest already exists."); if (!$this->io->confirm('Do you want to rebuild it?', false)) { return Command::FAILURE; } } - return $this->build($ref, $tarball); + $ref = $input->getOption('ref'); + return $this->build($ref, $dest); } private function build(string $ref, string $dest): int @@ -254,7 +262,8 @@ private function build(string $ref, string $dest): int $tar_cmd = [ 'tar', '--format=ustar', - '-cjf', + '--auto-compress', + '-cf', $dest, $this->plugin_name, ]; From 9d9a0657a1736f845c0bc32537fe425f7b5fb56d Mon Sep 17 00:00:00 2001 From: Benoit VIGNAL Date: Tue, 3 Feb 2026 16:39:10 +0100 Subject: [PATCH 4/7] fix: root directory should be default not be removed (only emptying it for test) --- tests/src/GLPITestCase.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/src/GLPITestCase.php b/tests/src/GLPITestCase.php index 42e7b3e35f9c..c4297583b966 100644 --- a/tests/src/GLPITestCase.php +++ b/tests/src/GLPITestCase.php @@ -205,7 +205,7 @@ protected function resetPictures() mkdir($dir); } - protected function removeDirectory(string $dir): void + protected function removeDirectory(string $dir, bool $delete_self = false): void { if (!is_dir($dir)) { return; @@ -213,12 +213,14 @@ protected function removeDirectory(string $dir): void $files = array_diff(scandir($dir), ['.', '..']); foreach ($files as $file) { if (is_dir("$dir/$file")) { - $this->removeDirectory("$dir/$file"); + $this->removeDirectory("$dir/$file", true); } else { unlink("$dir/$file"); } } - rmdir($dir); + if ($delete_self) { + rmdir($dir); + } } /** From 42f4bf38542288b8d75f3d2ed855474f35ea9b84 Mon Sep 17 00:00:00 2001 From: Benoit VIGNAL Date: Tue, 3 Feb 2026 16:59:41 +0100 Subject: [PATCH 5/7] feat: Remove ref and switch to checkout-index --- .../Plugin/Command/PluginReleaseCommand.php | 104 +++++++----------- 1 file changed, 42 insertions(+), 62 deletions(-) diff --git a/tools/src/Plugin/Command/PluginReleaseCommand.php b/tools/src/Plugin/Command/PluginReleaseCommand.php index fdc2b1fc34d3..d66dfa0cf65c 100644 --- a/tools/src/Plugin/Command/PluginReleaseCommand.php +++ b/tools/src/Plugin/Command/PluginReleaseCommand.php @@ -75,7 +75,6 @@ protected function configure(): void $this->setName('tools:plugin:release'); $this->setDescription('Build a GLPI plugin release archive.'); - $this->addOption('ref', 'r', InputOption::VALUE_REQUIRED, 'Git ref to build', 'HEAD'); $this->addOption('dest', 'd', InputOption::VALUE_REQUIRED, 'Destination path for the archive (e.g., /build/glpi-myplugin-1.0.0.tar.bz2)'); $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force rebuild even if release exists'); } @@ -117,84 +116,39 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $ref = $input->getOption('ref'); - return $this->build($ref, $dest); + return $this->build($dest); } - private function build(string $ref, string $dest): int + private function build(string $dest): int { - $this->io->title("Releasing plugin {$this->plugin_name}@{$ref}..."); + $this->io->title("Releasing plugin {$this->plugin_name}..."); $plugin_dir = $this->getPluginDirectory(); - // git ls-tree - $process = new Process(['git', 'ls-tree', '-r', $ref, '--name-only'], $plugin_dir); - $process->mustRun(); - $files = explode("\n", trim($process->getOutput())); - - // Filter banned - $banned = self::BANNED_FILES; - $ignore_release_file = $plugin_dir . '/.ignore-release'; - if (file_exists($ignore_release_file)) { - $lines = file($ignore_release_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $banned = array_merge($banned, $lines); - } - - $valid_files = []; - foreach ($files as $file) { - if (empty($file)) { - continue; - } - - $excluded = false; - foreach ($banned as $ban) { - if (fnmatch($ban, $file) || fnmatch($ban, basename($file)) || preg_match('#^' . preg_quote($ban, '#') . '#', $file)) { - $excluded = true; - break; - } - } - if (!$excluded) { - $valid_files[] = $file; - } - } - - // Git archive - $temp_tar = $this->dist_dir . '/temp.tar'; - $cmd = ['git', 'archive', '--prefix=' . $this->plugin_name . '/', '--output=' . $temp_tar, $ref]; - foreach ($valid_files as $f) { - $cmd[] = $f; - } - - $this->io->text("Archiving GIT ref {$ref}..."); - - $process = new Process($cmd, $plugin_dir); - $process->setTimeout(600); - $process->mustRun(function (string $type, string $buffer): void { - $this->output->write($buffer); - }); - - // Now we need to prepare (extract, add vendors, re-compress) + // Prepare working directory $src_dir = $this->dist_dir . '/src'; $src_subdir = $src_dir . '/' . $this->plugin_name; $fs = new Filesystem(); + if (is_dir($src_dir)) { $fs->remove($src_dir); } - if (!mkdir($src_dir)) { - $this->io->error(sprintf('Unable to create the `%s` directory.', $src_dir)); + if (!mkdir($src_subdir, 0o777, true)) { + $this->io->error(sprintf('Unable to create the `%s` directory.', $src_subdir)); return Command::FAILURE; } - $untar = new Process(['tar', '-xf', $temp_tar, '-C', $src_dir]); - $untar->mustRun(function (string $type, string $buffer): void { + // Export current index using checkout-index + $this->io->text("Exporting current index..."); + $process = new Process( + ['git', 'checkout-index', '--all', '--force', '--prefix=' . $src_subdir . '/'], + $plugin_dir + ); + $process->setTimeout(600); + $process->mustRun(function (string $type, string $buffer): void { $this->output->write($buffer); }); - if (!unlink($temp_tar)) { - $this->io->error(sprintf('Unable to delete the `%s` file.', $temp_tar)); - return Command::FAILURE; - } - // Composer if (file_exists($src_subdir . '/composer.json')) { $this->io->section("Installing composer dependencies..."); @@ -255,7 +209,33 @@ private function build(string $ref, string $dest): int $this->io->writeln("Locales compiled."); } - // Compress to bz2 + // Remove banned files before archiving + $this->io->section("Cleaning up banned files..."); + $banned = self::BANNED_FILES; + $ignore_release_file = $src_subdir . '/.ignore-release'; + if (file_exists($ignore_release_file)) { + $lines = file($ignore_release_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $banned = array_merge($banned, $lines); + } + + $finder = new Finder(); + $finder + ->ignoreDotFiles(false) + ->ignoreVCS(false) + ->in($src_subdir); + + foreach (iterator_to_array($finder->getIterator()) as $file) { + $relative_path = $file->getRelativePathname(); + foreach ($banned as $ban) { + if (fnmatch($ban, $relative_path) || fnmatch($ban, $file->getFilename()) || preg_match('#^' . preg_quote($ban, '#') . '#', $relative_path)) { + $fs->remove($file->getPathname()); + $this->io->writeln(" Removed: $relative_path", OutputInterface::VERBOSITY_VERBOSE); + break; + } + } + } + + // Create archive $this->io->section("Generating the archive"); $this->io->writeln("Target: $dest", OutputInterface::VERBOSITY_VERBOSE); From eb9cfd31b28ca543b2830e665e11c0a14cf02119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Anne?= Date: Tue, 3 Feb 2026 17:25:40 +0100 Subject: [PATCH 6/7] fix dir deletion --- tools/src/Plugin/Command/PluginReleaseCommand.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/src/Plugin/Command/PluginReleaseCommand.php b/tools/src/Plugin/Command/PluginReleaseCommand.php index d66dfa0cf65c..ba0d08e6be90 100644 --- a/tools/src/Plugin/Command/PluginReleaseCommand.php +++ b/tools/src/Plugin/Command/PluginReleaseCommand.php @@ -225,11 +225,20 @@ private function build(string $dest): int ->in($src_subdir); foreach (iterator_to_array($finder->getIterator()) as $file) { + /* @var \SplFileInfo $file */ $relative_path = $file->getRelativePathname(); foreach ($banned as $ban) { - if (fnmatch($ban, $relative_path) || fnmatch($ban, $file->getFilename()) || preg_match('#^' . preg_quote($ban, '#') . '#', $relative_path)) { + if ( + \file_exists($file->getRealPath()) + && ( + fnmatch($ban, $relative_path) + || fnmatch($ban, $file->getFilename()) + || preg_match('#^' . preg_quote($ban, '#') . '#', $relative_path) + || ($file->isDir() && rtrim($ban, '/') === $relative_path) + ) + ) { $fs->remove($file->getPathname()); - $this->io->writeln(" Removed: $relative_path", OutputInterface::VERBOSITY_VERBOSE); + $this->io->writeln(" Removed: $relative_path"); break; } } From 36fa480f381b334ce85836a624803d420647eea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Anne?= Date: Wed, 4 Feb 2026 08:41:52 +0100 Subject: [PATCH 7/7] Make path relative to cwd to benefits of shell completion --- PluginsMakefile.mk | 2 +- tools/src/Plugin/Command/PluginReleaseCommand.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/PluginsMakefile.mk b/PluginsMakefile.mk index e86285237514..dbdf2eac2ad5 100644 --- a/PluginsMakefile.mk +++ b/PluginsMakefile.mk @@ -86,7 +86,7 @@ locales-compile: ## Compile locales .PHONY: locales-compile plugin-release: ## Build and create plugin release based on HEAD ref, example: make plugin-release - @$(CONSOLE) tools:plugin:release --plugin=$(PLUGIN_DIR) --dest=dist/glpi-$(PLUGIN_DIR)-$(shell date +%Y%m%d).tar.bz2 + @$(CONSOLE) tools:plugin:release --plugin=$(PLUGIN_DIR) --dest=/var/www/glpi/plugins/$(PLUGIN_DIR)/dist/glpi-$(PLUGIN_DIR)-$(shell date +%Y%m%d).tar.bz2 .PHONY: plugin-release diff --git a/tools/src/Plugin/Command/PluginReleaseCommand.php b/tools/src/Plugin/Command/PluginReleaseCommand.php index ba0d08e6be90..9ded80b485e5 100644 --- a/tools/src/Plugin/Command/PluginReleaseCommand.php +++ b/tools/src/Plugin/Command/PluginReleaseCommand.php @@ -98,8 +98,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Resolve relative paths based on plugin directory - if (!str_starts_with($dest, '/')) { - $dest = $plugin_dir . '/' . $dest; + $fs = new Filesystem(); + if (!$fs->isAbsolutePath($dest)) { + $dest = \getcwd() . '/' . $dest; } // Ensure parent directory exists