├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md ├── behat.yml ├── composer.json ├── docs ├── api.md ├── bitbucket.md ├── cli.md ├── configuration.md ├── github.md ├── gitlab.md ├── hooks.md ├── index.md ├── installation.md ├── notifications.md ├── plugin-theme.md ├── project-setup.md └── self-managed.md ├── inc ├── CLI │ ├── CacheCommand.php │ ├── CommandNamespace.php │ ├── InfoCommand.php │ ├── LanguagePackCommand.php │ └── ProjectCommand.php ├── Configuration.php ├── Export.php ├── Loader.php ├── Loader │ ├── Base.php │ ├── Git.php │ ├── Mercurial.php │ └── Subversion.php ├── LoaderFactory.php ├── Plugin.php ├── Project.php ├── ProjectLocator.php ├── Repository.php ├── Repository │ ├── Base.php │ ├── Bitbucket.php │ ├── GitHub.php │ └── GitLab.php ├── RepositoryFactory.php ├── Runner.php ├── TranslationApiRoute.php ├── Updater.php ├── WebhookHandler.php ├── WebhookHandler │ ├── Base.php │ ├── Bitbucket.php │ ├── GitHub.php │ └── GitLab.php ├── WebhookHandlerFactory.php └── ZipProvider.php ├── phpstan.neon.dist ├── traduttore.php └── uninstall.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ### Changed 10 | * Require GlotPress 4.0. [#270] 11 | * Require at least PHP 8.1. [#270] 12 | * Added PHP translation file support. [#270] 13 | 14 | ### Fixed 15 | * Pass correct parameter for translation set in `traduttore.generate_zip_delay` filter. [#236] 16 | 17 | ## [3.2.0] - 2022-01-18 18 | 19 | ### Changed 20 | * Make project locator more strict when matching paths with existing data to prevent false positives. [#187] 21 | 22 | ### Fixed 23 | * Don't create JSON translation files for JavaScript source files. [#206] 24 | * Fix file check for `mergeWith` option. [#225] 25 | * GitLab: Use the correct `visibility_level` value for public repos. [#217] 26 | * GitLab: URL-encode the project name for the public repo check. [#217] 27 | 28 | ## [3.1.0] - 2020-07-20 29 | 30 | ### Added 31 | * Introduce `traduttore.map_entries_to_source` filter to change the mapping of sources to translation entries. Props @florianbrinkmann. [#170] 32 | * Support `application/x-www-form-urlencoded` as content type for GitHub webhooks. [#166] 33 | * Include file reference in JSON translation files. [#176] 34 | 35 | ### Fixed 36 | * Fix generating empty language pack ZIP files. Props @florianbrinkmann. [#168] 37 | * Fix compatibility with GlotPress 3.0 and its stricter type checks. [#174] 38 | 39 | ## [3.0.0] - 2019-03-15 40 | Due to the large number of changes in the release it is recommended to update all of the language packs. This can be done with the WP-CLI command `wp traduttore language-pack build --all`. 41 | 42 | ### Changed 43 | * Heavy architectural changes to make the plugin more modular. 44 | * All filters and actions now use `.` as the separator between the prefix and hook name instead of `_`. 45 | * Scheduling of cron events to reduce number of unnecessary builds and updates. 46 | * Bump Traduttore Registry version to 2.0. 47 | * Existing WP-CLI commands: 48 | * `wp traduttore build ` → `wp traduttore language-pack build ` 49 | * `wp traduttore cache clear ` → `wp traduttore project cache clear ` 50 | * `wp traduttore update ` → `wp traduttore project update ` 51 | 52 | ### Added 53 | * Support for Bitbucket.org repositories (Mercurial and Git). 54 | * Support for GitLab repositories. 55 | * Support for self-managed repositories (GitLab and others). 56 | * New REST API route for incoming webhooks (`traduttore/v1/incoming-webhook`). 57 | * Support for [JavaScript translations](https://make.wordpress.org/core/2018/11/09/new-javascript-i18n-support-in-wordpress/). 58 | * Greatly improved [documentation](https://wearerequired.github.io/traduttore/). 59 | * New WP-CLI commands: 60 | * `wp traduttore info` for information about the Traduttore setup. 61 | * `wp traduttore project info ` for information about a project. 62 | * `wp traduttore language-pack list ` for listing all language packs in a project. 63 | 64 | ### Deprecated 65 | * The REST API route `github-webhook/v1/push-event` for incoming webhooks is replaced by `traduttore/v1/incoming-webhook`. 66 | 67 | ### Removed 68 | * Remove all filters and actions with `_` as the separator. 69 | 70 | ## [2.0.3] - 2018-07-09 71 | ### Changed 72 | * Use HTTPS instead of SSH for cloning repositories if possible. 73 | 74 | ### Fixed 75 | * Fix uninstall routine and a few other smaller issues. 76 | 77 | ## [2.0.2] - 2018-06-25 78 | ### Added 79 | * Introduce `TRADUTTORE_WP_BIN` constant to allow overriding the path to WP-CLI. 80 | 81 | ### Fixed 82 | * Fix a few errors within the CLI commands. 83 | * Fix an error where deleting the local Git repository wasn't possible. 84 | * Make sure `wp_tempnam()` is always available. 85 | 86 | ## [2.0.1] - 2018-06-21 87 | ### Fixed 88 | * Fix a possible fatal error in the project locator class. 89 | 90 | ### Changed 91 | * Improve code formatting and inline documentation. 92 | 93 | ## 2.0.0 - 2018-06-19 94 | ### Added 95 | * CLI commands. 96 | * ZIP file generation. 97 | * Translation API. 98 | * Slack notifications. 99 | 100 | ## 1.0.0 - 2017-05-30 101 | ### Added 102 | * Initial release. 103 | 104 | [Unreleased]: https://github.com/wearerequired/traduttore/compare/3.2.0...HEAD 105 | [3.2.0]: https://github.com/wearerequired/traduttore/compare/3.1.0...3.2.0 106 | [3.1.0]: https://github.com/wearerequired/traduttore/compare/3.0.0...3.1.0 107 | [3.0.0]: https://github.com/wearerequired/traduttore/compare/2.0.3...3.0.0 108 | [2.0.3]: https://github.com/wearerequired/traduttore/compare/2.0.2...2.0.3 109 | [2.0.2]: https://github.com/wearerequired/traduttore/compare/2.0.1...2.0.2 110 | [2.0.1]: https://github.com/wearerequired/traduttore/compare/2.0.0...2.0.1 111 | 112 | [#166]: https://github.com/wearerequired/traduttore/issues/166 113 | [#168]: https://github.com/wearerequired/traduttore/issues/168 114 | [#170]: https://github.com/wearerequired/traduttore/issues/170 115 | [#174]: https://github.com/wearerequired/traduttore/issues/174 116 | [#176]: https://github.com/wearerequired/traduttore/issues/176 117 | [#187]: https://github.com/wearerequired/traduttore/issues/187 118 | [#206]: https://github.com/wearerequired/traduttore/issues/206 119 | [#225]: https://github.com/wearerequired/traduttore/issues/225 120 | [#217]: https://github.com/wearerequired/traduttore/issues/217 121 | [#236]: https://github.com/wearerequired/traduttore/pull/236 122 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute to Traduttore 2 | 3 | [Traduttore](https://github.com/wearerequired/traduttore) and [Traduttore Registry](https://github.com/wearerequired/traduttore-registry) both are open source projects. Everyone is welcome to contribute to them, no matter if it's with code, documentation, or something else. 4 | 5 | Bug reports and patches are very welcome. When contributing, please ensure you stick to the following guidelines. 6 | 7 | ## Writing a Bug Report 8 | 9 | When writing a bug report... 10 | 11 | * [Open an issue](https://github.com/wearerequired/traduttore/issues/new) 12 | * Follow the guidelines specified in the issue template 13 | 14 | We will take a look at your issue and either assign it keywords and a milestone or get back to you if there are open questions. 15 | 16 | ## Contributing Code 17 | 18 | When contributing code... 19 | 20 | * Fork the `master` branch of the repository on GitHub 21 | * Run `composer install` to install necessary development tools requirements 22 | * Make changes to the forked repository 23 | * Try to follow the [WordPress coding standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/), especially in terms of inline documentation. 24 | * Verify that using `composer lint` and `composer format` to automatically fix most coding standards issues 25 | * Ideally write unit tests for any code changes and verify them using `composer test` 26 | * Commit and push changes to your fork and [submit a pull request](https://github.com/wearerequired/traduttore/compare) to the `master` branch 27 | 28 | We try to review incoming pull requests as soon as possible and either merge them or make suggestions for some further improvements. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Traduttore 2 | 3 | ![PHPUnit](https://github.com/wearerequired/traduttore/workflows/PHPUnit/badge.svg) 4 | ![Lint](https://github.com/wearerequired/traduttore/workflows/Lint/badge.svg) 5 | [![codecov](https://codecov.io/gh/wearerequired/traduttore/branch/master/graph/badge.svg)](https://codecov.io/gh/wearerequired/traduttore) 6 | [![Latest Stable Version](https://poser.pugx.org/wearerequired/traduttore/v/stable)](https://packagist.org/packages/wearerequired/traduttore) 7 | [![Latest Unstable Version](https://poser.pugx.org/wearerequired/traduttore/v/unstable)](https://packagist.org/packages/wearerequired/traduttore) 8 | 9 | Traduttore is a WordPress plugin that allows you to host your own WordPress.org-style translation API for your WordPress projects. 10 | 11 | ## How it Works 12 | 13 | Working on a multilingual WordPress project with custom plugins and themes can be quite cumbersome. Every time you add new strings to the project, you have to regenerate POT files and update the PO/MO files for every locale. All these changes clutter the history of your Git repository and are prone to errors as well. Plus, you can't easily send these translation files to your clients. 14 | 15 | These problems don't exist for plugins and themes hosted on [WordPress.org](https://wordpress.org/), as they benefit from the [translate.wordpress.org](https://translate.wordpress.org/) translation platform. Whenever you publish a new version of your project, WordPress.org makes sure that new strings can be translated. With Traduttore, you can now get the same experience for your custom projects hosted on GitHub! 16 | 17 | Every time you commit something to your plugin or theme, Traduttore will extract translatable strings using [WP-CLI](https://github.com/wp-cli/i18n-command) and add import these to GlotPress. 18 | 19 | Then, you (or even your clients!) can translate these strings right from within GlotPress. Whenever translations are edited, Traduttore will create a ZIP file containing PO and MO files that can be consumed by WordPress. A list of available ZIP files is exposed over a simple API endpoint that is understood by WordPress. 20 | 21 | Using our little helper library called [Traduttore Registry](https://github.com/wearerequired/traduttore-registry), you can then tell WordPress that translations for your project should be loaded from that API endpoint. 22 | 23 | After that, you never have to worry about the translation workflow ever again! 24 | 25 | ## Features 26 | 27 | * Automatic string extraction 28 | * ZIP file generation and caching 29 | * Works with any WordPress plugin or theme hosted on GitHub 30 | * Custom WP-CLI commands to manage translations 31 | * Supports [Restricted Site Access](https://de.wordpress.org/plugins/restricted-site-access/) 32 | * Supports sending [Slack](https://wordpress.org/plugins/slack/) notifications 33 | 34 |
35 | 36 | [![a required open source product - let's get in touch](https://media.required.com/images/open-source-banner.png)](https://required.com/en/lets-get-in-touch/) 37 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | default: 4 | contexts: 5 | - Required\Traduttore\Tests\Behat\FeatureContext 6 | paths: 7 | - tests/features 8 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wearerequired/traduttore", 3 | "description": "WordPress.org-style translation API for your WordPress projects.", 4 | "license": "GPL-2.0-or-later", 5 | "type": "wordpress-plugin", 6 | "keywords": [ 7 | "wordpress", 8 | "glotpress", 9 | "translations" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "required", 14 | "email": "info@required.ch", 15 | "homepage": "https://required.com", 16 | "role": "Company" 17 | }, 18 | { 19 | "name": "Dominik Schilling", 20 | "email": "dominik@required.ch", 21 | "role": "Developer" 22 | }, 23 | { 24 | "name": "Ulrich Pogson", 25 | "email": "ulrich@required.ch", 26 | "role": "Developer" 27 | }, 28 | { 29 | "name": "Pascal Birchler", 30 | "role": "Developer" 31 | } 32 | ], 33 | "homepage": "https://github.com/wearerequired/traduttore", 34 | "support": { 35 | "issues": "https://github.com/wearerequired/traduttore/issues" 36 | }, 37 | "require": { 38 | "php": ">=8.1", 39 | "ext-json": "*", 40 | "ext-zip": "*", 41 | "wearerequired/traduttore-registry": "^2.0" 42 | }, 43 | "require-dev": { 44 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", 45 | "ergebnis/composer-normalize": "^2.42", 46 | "php-stubs/wordpress-tests-stubs": "^6.5", 47 | "php-stubs/wp-cli-stubs": "^2.10", 48 | "phpstan/extension-installer": "^1.3", 49 | "phpstan/phpstan-deprecation-rules": "^1.2", 50 | "phpstan/phpstan-phpunit": "^1.4", 51 | "swissspidy/phpstan-no-private": "^0.2.0", 52 | "szepeviktor/phpstan-wordpress": "^1.3", 53 | "wearerequired/coding-standards": "^6.0", 54 | "wp-cli/extension-command": "^2.0", 55 | "wp-cli/rewrite-command": "^2.0", 56 | "wp-cli/wp-cli-tests": "^4.2.9", 57 | "wpackagist-plugin/glotpress": "^4.0.0", 58 | "yoast/phpunit-polyfills": "^2.0.1" 59 | }, 60 | "suggest": { 61 | "wpackagist-plugin/slack": "Send Slack notifications for various events" 62 | }, 63 | "repositories": [ 64 | { 65 | "type": "composer", 66 | "url": "https://wpackagist.org" 67 | } 68 | ], 69 | "autoload": { 70 | "psr-4": { 71 | "Required\\Traduttore\\": "inc" 72 | } 73 | }, 74 | "autoload-dev": { 75 | "psr-4": { 76 | "Required\\Traduttore\\Tests\\": "tests/phpunit/tests", 77 | "Required\\Traduttore\\Tests\\Behat\\": "tests/behat", 78 | "Required\\Traduttore\\Tests\\Utils\\": "tests/phpunit/utils" 79 | } 80 | }, 81 | "config": { 82 | "allow-plugins": { 83 | "composer/installers": true, 84 | "dealerdirect/phpcodesniffer-composer-installer": true, 85 | "ergebnis/composer-normalize": true, 86 | "phpstan/extension-installer": true 87 | }, 88 | "process-timeout": 7200, 89 | "sort-packages": true 90 | }, 91 | "extra": { 92 | "branch-alias": { 93 | "dev-master": "3.x-dev" 94 | }, 95 | "installer-paths": { 96 | "vendor/wordpress-plugin/{$name}/": [ 97 | "type:wordpress-plugin" 98 | ] 99 | }, 100 | "webroot-dir": "app/wordpress-core", 101 | "webroot-package": "wordpress/wordpress" 102 | }, 103 | "scripts": { 104 | "analyze": "vendor/bin/phpstan analyze --no-progress --memory-limit=1024M", 105 | "behat": "run-behat-tests", 106 | "behat-rerun": "rerun-behat-tests", 107 | "format": "vendor/bin/phpcbf --report-summary --report-source .", 108 | "lint": "vendor/bin/phpcs --report-summary --report-source .", 109 | "phpunit": "vendor/bin/phpunit", 110 | "prepare-tests": "install-package-tests", 111 | "test": [ 112 | "@behat", 113 | "@phpunit" 114 | ] 115 | }, 116 | "scripts-descriptions": { 117 | "analyze": "Run static analysis", 118 | "behat": "Run functional tests", 119 | "behat-rerun": "Re-run failed functional tests", 120 | "format": "Detect and automatically fix most coding standards issues", 121 | "lint": "Detect coding standards issues", 122 | "phpunit": "Run unit tests", 123 | "prepare-tests": "Prepare functional tests", 124 | "test": "Run all tests at once" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: REST API 4 | nav_order: 13 5 | --- 6 | 7 | # REST API 8 | 9 | Traduttore adds new routes to both the [WordPress REST API](https://developer.wordpress.org/rest-api/) as well as the API provided by GlotPress. 10 | 11 | ## API Endpoints 12 | 13 | ### `/traduttore/v1/incoming-webhook` 14 | 15 | **Methods:** 16 | 17 | * `POST` 18 | 19 | Traduttore can be set up to listen to incoming webhooks from GitHub. This way, translations can be updated every time you push changes to your GitHub repository. 20 | 21 | Check out the [Getting Started](installation.md) guide to learn how to set up webhooks. 22 | 23 | **Example:** 24 | 25 | `https:///wp-json/traduttore/v1/incoming-webhook` 26 | 27 | ### `/github-webhook/v1/push-event` 28 | 29 | **Methods:** 30 | 31 | * `POST` 32 | 33 | This **deprecated** REST API route works the same way as `/traduttore/v1/incoming-webhook`, except only for GitHub repositories. For backward compatibility reasons it has not been removed. 34 | 35 | Users are encouraged to use the `/traduttore/v1/incoming-webhook` route for webhooks for all of the providers. 36 | 37 | **Example:** 38 | 39 | `https:///wp-json/github-webhook/v1/push-event` 40 | 41 | ## `/api/translations/` 42 | 43 | **Methods:** 44 | 45 | * `GET` 46 | 47 | This API route is used to distribute all the available language packs for a given project. This way, a WordPress site can be configured to download translations for a specific plugin via the API. 48 | 49 | **Example:** 50 | 51 | Fetching `https:///api/translations/my-project` would result in a response like this: 52 | 53 | ```json 54 | { 55 | "translations": [ 56 | { 57 | "language": "de_DE", 58 | "version": "1.0", 59 | "updated": false, 60 | "english_name": "German", 61 | "native_name": "Deutsch", 62 | "package": "https:///content/traduttore/my-project-de_DE.zip", 63 | "iso": [ 64 | "de" 65 | ] 66 | } 67 | ] 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/bitbucket.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Bitbucket Repository Configuration 4 | nav_order: 5 5 | --- 6 | 7 | # Bitbucket Repository Configuration 8 | 9 | Traduttore supports both private and public Git repositories hosted on [Bitbucket.org](https:/bitbucket.org). 10 | 11 | Mercurial repositores are not supported at this time. If you want to use Traduttore with Mercurial repositories, please [open an issue in our bug tracker](https://github.com/wearerequired/traduttore/issues). 12 | 13 | ## Repository Access 14 | 15 | Traduttore connects to Bitbucket via either HTTPS or SSH to fetch a project's repository. If your projects are not public, you need to make sure that the server has access to them by providing an SSH key. You can do this by adding an [access key](https://confluence.atlassian.com/bitbucket/access-keys-294486051.html) in your repository settings. 16 | 17 | ## Webhooks 18 | 19 | To enable automatic string extraction from your Bitbucket projects, you need to create a new webhook for each of them. 20 | 21 | 1. In your repository, go to Settings -> Webhooks. You might need to enter your password. 22 | 2. Click on "Add webhook". 23 | 3. Enter a descriptive title and set `https://.com/wp-json/traduttore/v1/incoming-webhook` as the URL. 24 | 5. Make sure the `Status` is "Active" 25 | 6. Keep "Repository push" as the trigger. 26 | 27 | Now, every time you push changes to Bitbucket, Traduttore will get notified and then attempts to update the project's translatable strings automatically. 28 | 29 | **Note:** If you're using *Bitbucket Server*, you can optionally define a secret that should be sent with each request in the webhook settings. For this to work the `TRADUTTORE_BITBUCKET_SYNC_SECRET` constant needs to be defined in your `wp-config.php` file with the same secret. 30 | 31 | Check out the [Configuration](configuration.md) section for a list of possible constants. 32 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: CLI Commands 4 | nav_order: 11 5 | --- 6 | 7 | # CLI Commands 8 | 9 | Traduttore requires [WP-CLI](https://wp-cli.org/) 2.0 or newer to be installed on the server. 10 | 11 | You can define `TRADUTTORE_WP_BIN` in your `wp-config.php` file to tell Traduttore where the WP-CLI executable is. The default is `wp`. 12 | 13 | ## Generate language packs 14 | 15 | Generate language packs for one or more projects. 16 | 17 | ```bash 18 | wp traduttore language-pack build 19 | ``` 20 | 21 | Language packs will automatically be updated upon translation changes. This WP-CLI command is mostly useful for debugging / testing. 22 | 23 | Use the `--force` flag to force ZIP file generation, even if there were no changes since the last build. 24 | 25 | Use the `--all` flag to generate the language packs for all active projects. 26 | 27 | ## List project language packs 28 | 29 | List language packs for the given project. 30 | 31 | ```bash 32 | wp traduttore language-pack list 33 | ```` 34 | 35 | ## Update translations from remote 36 | 37 | Updates project translations from source code repository. 38 | 39 | Pulls the latest changes, extracts translatable strings and imports them into GlotPress. 40 | 41 | ```bash 42 | wp traduttore project update 43 | ``` 44 | 45 | Use the `--delete` flag to first delete the existing local repository. 46 | 47 | ## Clearing the cached source code repository 48 | 49 | Removes the cached source code repository for a given project. 50 | 51 | Useful when the local repository was somehow corrupted. 52 | 53 | ```bash 54 | wp traduttore project cache clear 55 | ```` 56 | 57 | ## Show various details about a project 58 | 59 | There's a command to print some helpful debug information about a given project. 60 | 61 | This includes things like the text domain and repository URLs. 62 | 63 | ```bash 64 | wp traduttore project info 65 | ``` 66 | 67 | ## Show various details about the environment 68 | 69 | There's a command to print some helpful debug information about Traduttore. 70 | 71 | This includes things like the plugin version and path to the cache directory. 72 | 73 | ```bash 74 | wp traduttore info 75 | ``` 76 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Configuration 4 | nav_order: 3 5 | --- 6 | 7 | # Configuration 8 | 9 | ## Constants 10 | 11 | The following constants can be defined to configure Traduttore: 12 | 13 | * `TRADUTTORE_BITBUCKET_SYNC_SECRET`: Secret token for incoming Bitbucket webhook requests. 14 | * `TRADUTTORE_GITHUB_SYNC_SECRET`: Secret token for incoming GitHub webhook requests. 15 | * `TRADUTTORE_GITLAB_SYNC_SECRET`: Secret token for incoming GitLab webhook requests. 16 | * `TRADUTTORE_WP_BIN`: Path to the WP-CLI executable on the system. 17 | 18 | ## Restricted Site Access 19 | 20 | Sometimes you might not want your translation platform to be publicly accessible. However, to function properly some parts of the site need to be open to the public in order for Traduttore to work. 21 | 22 | For this case, it's recommended to use the free [Restricted Site Access](https://wordpress.org/plugins/restricted-site-access/) plugin. Traduttore integrates well with this plugin by making sure the REST API endpoints remain unaffected by it. 23 | 24 | There's no need for any manual configuration, everything happens in the background. 25 | 26 | ## Task Scheduler 27 | 28 | Traduttore relies on WordPress' built-in cron functionality to schedule single events. The WordPress cron normally relies on users visiting your WordPress site in order to execute scheduled events. 29 | 30 | To make sure your events are executed on time, we suggest setting up a system cron job that runs reliably every few minutes. 31 | 32 | [Learn more about hooking WP-Cron into the system task scheduler](https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/). 33 | 34 | There are two tasks that are scheduled: 35 | 1. The `traduttore.update` task is created when the webhook is hit. The task runs by default 3 minutes after being triggered. 36 | 2. The `traduttore.generate_zip` tasks in created when a translation is updated. This tasks runs by default 5 minutes after being triggered. 37 | 38 | ## String Extraction 39 | 40 | Traduttore uses the [WP-CLI i18n command](https://github.com/wp-cli/i18n-command) to extract all available strings from your WordPress plugin or theme. 41 | 42 | By default, it scans both PHP and JavaScript files looking for strings where the text domain matches the one of your project. By default, the "Text Domain" header of the plugin or theme is used. If none is provided, it falls back to the project slug. 43 | 44 | In some cases it might be needed to customize this behavior. Traduttore allows you to do so through a special configuration file, `traduttore.json`. 45 | 46 | Right now, the following options are available: 47 | 48 | * `mergeWith`: The path to an existing POT file in your project that strings should be extracted from as well. 49 | * `textDomain`: An alternative text domain to override the default one. 50 | * `exclude`: A list of files and paths that should be skipped for string extraction. 51 | Simple glob patterns can be used, i.e. `--exclude=foo-*.php` excludes any PHP file with the `foo-` prefix. 52 | Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. 53 | The following files and folders are always excluded: node_modules, .git, .svn, .CVS, .hg, vendor, *.min.js. 54 | 55 | Here's an example `traduttore.json` file: 56 | 57 | ```json 58 | { 59 | "mergeWith": "languages/some-more-strings.pot", 60 | "textDomain": "foo", 61 | "exclude": [ 62 | "some/file.php", 63 | "foo-directory" 64 | ] 65 | } 66 | ``` 67 | 68 | **Note:** Alternatively you can provide this configuration by adding it to your `composer.json` file. Here's an example: 69 | 70 | ```json 71 | { 72 | "extra": { 73 | "traduttore": { 74 | "mergeWith": "languages/some-more-strings.pot", 75 | "textDomain": "foo" 76 | } 77 | } 78 | } 79 | ``` 80 | 81 | In the future, more options might be supported. 82 | -------------------------------------------------------------------------------- /docs/github.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: GitHub Repository Configuration 4 | nav_order: 6 5 | --- 6 | 7 | # GitHub Repository Configuration 8 | 9 | Traduttore supports both private and public Git repositories hosted on [GitHub.com](https://github.com). 10 | 11 | ## Repository Access 12 | 13 | Traduttore connects to GitHub via either HTTPS or SSH to fetch a project's repository. If you're projects are not public, you need to make sure that the server has access to them by providing an SSH key. Ideally, you'd create a so-called [machine user](https://developer.github.com/v3/guides/managing-deploy-keys/#machine-users) for this purpose. 14 | 15 | You can learn more about this at [Connecting to GitHub with SSH](https://help.github.com/articles/connecting-to-github-with-ssh/) 16 | 17 | ## Webhooks 18 | 19 | To enable automatic string extraction from your GitHub projects, you need to create a new webhook for each of them. 20 | 21 | 1. In your repository, go to Settings -> Webhooks. You might need to enter your password. 22 | 2. Click on "Add webhook". 23 | 3. Set `https://.com/wp-json/traduttore/v1/incoming-webhook` as the payload URL. 24 | 4. Choose `application/json` as the content type. 25 | 5. Enter and remember a secret key. 26 | 6. In the "Which events would you like to trigger this webhook?" section, select only the `push` event. 27 | 28 | Now, every time you push changes to GitHub, Traduttore will get notified and then attempts to update the project's translatable strings automatically. 29 | 30 | **Note:** The `TRADUTTORE_GITHUB_SYNC_SECRET` constant needs to be defined in your `wp-config.php` file to enable webhooks. Use the secret from step 5 for this. 31 | 32 | Check out the [Configuration](configuration.md) section for a list of possible constants. 33 | -------------------------------------------------------------------------------- /docs/gitlab.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: GitLab Repository Configuration 4 | nav_order: 7 5 | --- 6 | 7 | # GitLab Repository Configuration 8 | 9 | Traduttore supports both private and public Git repositories hosted on [GitLab.com](https://gitlab.com) as well as self-managed GitLab instances. 10 | 11 | ## Repository Access 12 | 13 | Traduttore connects to GitLab via either HTTPS or SSH to fetch a project's repository. If you're projects are not public, you need to make sure that the server has access to them by providing an SSH key. Ideally, you'd create a so-called [machine user](https://developer.github.com/v3/guides/managing-deploy-keys/#machine-users) for this purpose. 14 | 15 | You can learn more about this at [Connecting to GitHub with SSH](https://help.github.com/articles/connecting-to-github-with-ssh/) 16 | 17 | ## Webhooks 18 | 19 | To enable automatic string extraction from your GitLab projects, you need to create a new webhook for each of them. 20 | 21 | 1. In your repository, go to Settings -> Integrations. 22 | 3. Enter `https://.com/wp-json/traduttore/v1/incoming-webhook` as the URL. 23 | 5. Enter the secret token defined in `TRADUTTORE_GITLAB_SYNC_SECRET`. 24 | 6. In the "Trigger" section, select only `Push events`. 25 | 26 | Now, every time you push changes to GitLab, Traduttore will get notified and then attempts to update the project's translatable strings automatically. 27 | 28 | **Note:** The `TRADUTTORE_GITLAB_SYNC_SECRET` constant needs to be defined in your `wp-config.php` file to enable webhooks. Use the secret from step 5 for this. 29 | 30 | Check out the [Configuration](configuration.md) section for a list of possible constants. 31 | 32 | ## Self-managed GitLab 33 | 34 | Some people prefer to install GitLab on their own system instead of using [GitLab.com](https://gitlab.com). 35 | 36 | Traduttore tries to automatically recognize self-managed repositories to the best of its ability. As soon as it receives a webhook for a repository, it stores all needed information in the database for later use. 37 | 38 | If no incoming webhooks are set up or received, some manual configuration is still involved. Here's how you can tell Traduttore how to properly locate your repository in that case: 39 | 40 | Let's say your GitLab instance is available via `gitlab.example.com`. To tell Traduttore this should be treated as such, you can hook into the `traduttore.repository` filter to do so. Here's an example: 41 | 42 | ```php 43 | class MySelfhostedGitLabRepository extends \Required\Traduttore\Repository\GitLab { 44 | /** 45 | * GitLab API base URL. 46 | * 47 | * Used to access information about a repository's visibility level. 48 | */ 49 | public const API_BASE = 'https://gitlab.example.com/api/v4'; 50 | } 51 | 52 | /** 53 | * Filters the repository information Traduttore uses for self-managed GitLab repositories. 54 | * 55 | * @param \Required\Traduttore\Repository|null $repository Repository instance. 56 | * @param \Required\Traduttore\Project $project Project information. 57 | * @return \Required\Traduttore\Repository|null Filtered Repository instance. 58 | */ 59 | function myplugin_filter_traduttore_repository( \Required\Traduttore\Repository $repository = null, \Required\Traduttore\Project $project ) { 60 | $url = $project->get_source_url_template(); 61 | $host = $url ? wp_parse_url( $url, PHP_URL_HOST ) : null; 62 | 63 | if ( 'gitlab.example.com' === $host ) { 64 | return new MySelfhostedGitLabRepository( $project ); 65 | } 66 | 67 | return $repository; 68 | } 69 | 70 | add_filter( 'traduttore.repository', 'myplugin_filter_traduttore_repository', 10, 2 ); 71 | ``` 72 | 73 | That's all. This way Traduttore knows that `gitlab.example.com` hosts a GitLab instance and that it can download repositories hosted there using the built-in Git loader. 74 | 75 | Ideally, you put this code into a custom WordPress plugin in your WordPress site that runs Traduttore. 76 | 77 | [Learn more about developing WordPress plugins](https://developer.wordpress.org/plugins/). 78 | 79 | In the future, this step might be replaced by a WP-CLI command or an extended settings UI in GlotPress. 80 | -------------------------------------------------------------------------------- /docs/hooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Hooks and Filters 4 | nav_order: 12 5 | --- 6 | 7 | # Hooks and Filters 8 | 9 | All WordPress hooks and filters provided by Traduttore are prefixed with `traduttore.`. 10 | 11 | ## Action Hooks 12 | 13 | ### `traduttore.updated` 14 | 15 | **Since:** 3.0.0 16 | 17 | Fires after translations have been updated. 18 | 19 | **Parameters:** 20 | 21 | * `$project`: The project that was updated. 22 | * `$stats`: Stats about the number of imported translations. 23 | * `$translations`: PO object containing all the translations from the POT file. 24 | 25 | ---- 26 | 27 | ### `traduttore.zip_generated` 28 | 29 | **Since:** 3.0.0 30 | 31 | Fires after a language pack for a given translation set has been generated. 32 | 33 | **Parameters:** 34 | 35 | * `$file`: Path to the generated language pack. 36 | * `$url`: URL to the generated language pack. 37 | * `$translation_set`: Translation set the language pack is for. 38 | 39 | ## Filters 40 | 41 | ### `traduttore.git_clone_use_https` 42 | 43 | **Since:** 3.0.0 44 | 45 | Filters whether HTTPS or SSH should be used to clone a repository. 46 | 47 | **Parameters:** 48 | 49 | * `$use_https`: Whether to use HTTPS or SSH. Defaults to HTTPS for public repositories. 50 | * `$repository`: The current repository. 51 | 52 | ---- 53 | 54 | ### `traduttore.git_clone_url` 55 | 56 | **Since:** 3.0.0 57 | 58 | Filters the URL used to clone a Git repository. 59 | 60 | **Parameters:** 61 | 62 | * `$clone_url`: The URL to clone a Git repository. 63 | * `$repository`: The current repository. 64 | 65 | ---- 66 | 67 | ### `traduttore.git_https_credentials` 68 | 69 | **Since:** 3.0.0 70 | 71 | Filters the credentials to be used for connecting to a Git repository via HTTPS. 72 | 73 | **Parameters:** 74 | 75 | * `$credentials`: Git credentials in the form `username:password`. Default empty string. 76 | * `$repository`: The current repository. 77 | 78 | ---- 79 | 80 | ### `traduttore.zip_generated_send_notification` 81 | 82 | **Since:** 3.0.0 83 | 84 | Filters whether a Slack notification for translation updates from GitHub should be sent. 85 | 86 | **Parameters:** 87 | 88 | * `$send_message`: Whether to send a notification or not. Default true. 89 | * `$translation_set`: Translation set the language pack is for. 90 | * `$project`: The project that was updated. 91 | 92 | ---- 93 | 94 | ### `traduttore.zip_generated_notification_message` 95 | 96 | **Since:** 3.0.0 97 | 98 | Filters the Slack notification message for when a new language pack has been built. 99 | 100 | **Parameters:** 101 | 102 | * `$message`: The notification message. 103 | * `$translation_set`: Translation set the language pack is for. 104 | * `$project`: The project that was updated. 105 | 106 | ---- 107 | 108 | ### `traduttore.updated_send_notification` 109 | 110 | **Since:** 3.0.0 111 | 112 | Filters whether a Slack notification for translation updates from GitHub should be sent. 113 | 114 | Make sure to set up Slack notifications first, as outlined in the [Notifications](notifications.md) section. 115 | 116 | **Parameters:** 117 | 118 | * `$send_message`: Whether to send a notification or not. Defaults to true, unless there were no string changes at all. 119 | * `$project`: The project that was updated. 120 | * `$stats`: Stats about the number of imported translations. 121 | 122 | ---- 123 | 124 | ### `traduttore.updated_notification_message` 125 | 126 | **Since:** 3.0.0 127 | 128 | Filters the Slack notification message when new translations are updated. 129 | 130 | **Parameters:** 131 | 132 | * `$message`: The notification message. 133 | * `$project`: The project that was updated. 134 | * `$stats`: Stats about the number of imported translations. 135 | 136 | ---- 137 | 138 | ### `traduttore.generate_zip_delay` 139 | 140 | **Since:** 3.0.0 141 | 142 | Filters the delay for scheduled language pack generation. 143 | 144 | **Parameters:** 145 | 146 | * `$delay`: Delay in minutes. Default is 5 minutes. 147 | * `$translation_set`: Translation set the ZIP generation will be scheduled for. 148 | 149 | ---- 150 | 151 | ### `traduttore.update_delay` 152 | 153 | **Since:** 3.0.0 154 | 155 | Filters the delay for scheduled project updates. 156 | 157 | **Parameters:** 158 | 159 | * `$delay`: Delay in minutes. Default is 3 minutes. 160 | * `$project`: The current project. 161 | 162 | ---- 163 | 164 | ### `traduttore.webhook_secret` 165 | 166 | **Since:** 3.0.0 167 | 168 | Filters the sync secret for an incoming webhook request. 169 | 170 | **Parameters:** 171 | 172 | * `$secret`: Webhook sync secret. 173 | * `$handler`: The current webhook handler instance. 174 | * `$project`: The current project if found. 175 | 176 | ---- 177 | 178 | ### `traduttore.content_url` 179 | 180 | **Since:** 3.0.0 181 | 182 | Filters the URL to Traduttore's cache directory. 183 | 184 | Useful when language packs should be stored somewhere else. 185 | 186 | **Parameters:** 187 | 188 | * `$url`: Cache directory URL. 189 | 190 | ---- 191 | 192 | ### `traduttore.content_dir` 193 | 194 | **Since:** 3.0.0 195 | 196 | Filters the path to Traduttore's cache directory. 197 | 198 | Useful when language packs should be stored somewhere else. 199 | 200 | **Parameters:** 201 | 202 | * `$dir`: Cache directory path. 203 | 204 | ---- 205 | 206 | ### `traduttore.map_entries_to_source` 207 | 208 | **Since:** 3.1.0 209 | 210 | Filters the mapping of sources to translation entries. 211 | 212 | Useful when the source and dist path of JavaScript sources does not match and not only differ in `.js` and `.min.js`. 213 | 214 | **Parameters:** 215 | 216 | * `$mapping`: The mapping of sources to translation entries. 217 | * `$entries`: The translation entries to map. 218 | * `$project`: The project that is exported. 219 | 220 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Documentation 4 | nav_order: 1 5 | permalink: / 6 | --- 7 | 8 | # Documentation 9 | 10 | ## Getting Started 11 | 12 | * [Installation](installation.md) 13 | * [Configuration](configuration.md) 14 | 15 | ## Integration 16 | 17 | * [GlotPress Project Configuration](project-setup.md) 18 | * [Bitbucket Repository Configuration](bitbucket.md) 19 | * [GitHub Repository Configuration](github.md) 20 | * [GitLab Repository Configuration](gitlab.md) 21 | * [Self-managed Repository Configuration](self-managed.md) 22 | * [Integrating Traduttore](integration.md) 23 | * [Notifications](notifications.md) 24 | 25 | ## API 26 | 27 | * [CLI Commands](cli.md) 28 | * [Available Hooks](hooks.md) 29 | * [REST API](api.md) 30 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Installation 4 | nav_order: 2 5 | --- 6 | 7 | # Installation 8 | 9 | The goal for Traduttore is to make it as easy as possible to supercharge your WordPress internationalization workflow. 10 | 11 | ## System Requirements 12 | 13 | ### WordPress 14 | 15 | Traduttore is a WordPress plugin that sits on top of the [GlotPress](https://glotpress.org/) plugin. That means you need to have both WordPress and GlotPress installed. 16 | 17 | GlotPress is available as a WordPress plugin through the plugin directory. Installing it is as simple as searching for “GlotPress” and installing it. After activating the plugin, GlotPress can be accessed via `https:///glotpress/`. There's also a [GlotPress manual](https://glotpress.blog/the-manual/) that you can follow. 18 | 19 | To send Slack notifications, Traduttore requires a separate WordPress plugin. [Learn more about setting up notifications](notifications.md). 20 | 21 | ### Server 22 | 23 | Traduttore requires at least PHP 7.1. 24 | 25 | To download the latest code from your source code repositories, Traduttore requires the respective version control to be installed on the server. Depending on the projects it may be Git, Subversion or Mercurial. 26 | 27 | For string extraction Traduttore requires [WP-CLI](https://wp-cli.org/) 2.0 or newer. [Learn more about the available CLI commands](cli.md). 28 | 29 | If you're not sure whether Git or WP-CLI are available on your system, please contact your hosting provider or run the WP-CLI command `wp traduttore info`. 30 | 31 | ## Installing Traduttore 32 | 33 | If you're using [Composer](https://getcomposer.org/) to manage dependencies, you can use the following command to add the plugin to your site: 34 | 35 | ```bash 36 | composer require wearerequired/traduttore 37 | ``` 38 | 39 | Alternatively, you can download a ZIP file containing the plugin on [GitHub](https://github.com/wearerequired/traduttore). Extract the ZIP file, run ` composer install --no-progress --prefer-dist --optimize-autoloader`, archive the files again and upload the new ZIP file in your WordPress admin screen. 40 | 41 | Afterwards, activating Traduttore is all you need to do. There's no special settings UI or anything for it. 42 | -------------------------------------------------------------------------------- /docs/notifications.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Notifications 4 | nav_order: 10 5 | --- 6 | 7 | # Notifications 8 | 9 | Traduttore has built-in support for the unofficial [Slack WordPress plugin](https://wordpress.org/plugins/slack/). 10 | 11 | Using this plugin you can set up notifications to be sent to your Slack workspace whenever translations are updated or language packs are built. 12 | 13 | ## Setting up Slack notifications 14 | 15 | You can download and install the [Slack plugin](https://wordpress.org/plugins/slack/) from WordPress.org. 16 | 17 | After you activate the plugin, head over to the new Slack top level menu item in the admin bar and create new notifications. 18 | 19 | Traduttore adds two types of notifications: 20 | 21 | * When a new language pack is built 22 | * When new translations are updated for a project 23 | 24 | ### Example 25 | 26 | You can see an example screenshot of these notifications in the [introductory blog post](https://required.com/en/translation-workflow-glotpress-traduttore/). 27 | -------------------------------------------------------------------------------- /docs/plugin-theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Plugin & Theme Integration 4 | nav_order: 9 5 | --- 6 | 7 | # Plugin & Theme Integration 8 | 9 | Using our little helper library called [Traduttore Registry](https://github.com/wearerequired/traduttore-registry), you can then tell WordPress that translations for your project should be loaded from Traduttore. 10 | 11 | **Note:** Traduttore Registry requires PHP 7.1 or higher. 12 | 13 | ## Setting up Traduttore Registry 14 | 15 | If you're using [Composer](https://getcomposer.org/) to manage dependencies, you can use the following command to add the library to your WordPress plugin or theme: 16 | 17 | ```bash 18 | composer require wearerequired/traduttore-registry 19 | ``` 20 | 21 | After that, you can use `Required\Traduttore_Registry\add_project( $type, $slug, $api_url )` in your theme or plugin. 22 | 23 | **Note:** Alternatively, you could copy the library's code to your project. Also, on a multisite install it's recommended to use it in a must-use plugin. 24 | 25 | **Parameters:** 26 | 27 | * `$type`: either `plugin` or `theme`. 28 | * `$slug`: must match the theme/plugin directory slug. 29 | * `$api_url`: the URL to the Traduttore project translation API. 30 | 31 | ### Example 32 | 33 | Here's an example of how you can use `add_project()` in your plugin or theme: 34 | 35 | ```php 36 | \Required\Traduttore_Registry\add_project( 37 | 'plugin', 38 | 'example-plugin', 39 | 'https:///api/translations/acme/acme-plugin/' 40 | ); 41 | 42 | \Required\Traduttore_Registry\add_project( 43 | 'theme', 44 | 'example-theme', 45 | 'https:///api/translations/acme/acme-theme/' 46 | ); 47 | ``` 48 | 49 | Replace `glotpress-url` with the home URL of your site and the base path to the GlotPress installation, by default `/glotpress`. 50 | It's important that the slug matches the folder name of the plugin or theme. The URL is the one of the [Traduttore REST API](api.md), which should be publicly accessible. 51 | 52 | Ideally you call `add_project()` in a function hooked to `init`, e.g. like this: 53 | 54 | ```php 55 | function myplugin_init_traduttore() { 56 | \Required\Traduttore_Registry\add_project( 57 | 'plugin', 58 | 'example-plugin', 59 | 'https:///api/translations/acme/acme-plugin/' 60 | ); 61 | } 62 | add_action( 'init', 'myplugin_init_traduttore' ); 63 | ``` 64 | -------------------------------------------------------------------------------- /docs/project-setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: GlotPress Project Configuration 4 | nav_order: 4 5 | --- 6 | 7 | # Project Setup 8 | 9 | When you successfully installed Traduttore, it's time to set up your first project. 10 | 11 | We suggest following the [GlotPress manual](https://glotpress.blog/the-manual/creating-a-new-project/) for creating a new project. 12 | 13 | The one field we care about in this step is the `Source file URL` field. You can use this field to have a link created to the source code for the translators in the string editor. 14 | 15 | In addition to that, `Source file URL` is the field Traduttore uses to detect your repository. 16 | 17 | If your code is hosted in the `acme/my-awesome-plugin` repository on GitHub, you would enter the following URL into this field: 18 | 19 | ``` 20 | https://github.com/acme/my-awesome-plugin/blob/master/%file%#L%line% 21 | ``` 22 | 23 | Similarly, the `Source file URL` would look as follows for a GitLab project: 24 | 25 | ``` 26 | https://gitlab.com/acme/my-awesome-plugin/blob/master/%file%#L%line% 27 | ``` 28 | 29 | For projects hosted on Bitbucket, the format is slightly different: 30 | 31 | ``` 32 | https://bitbucket.org/acme/my-awesome-plugin/src/master/%file%#%file%-%line% 33 | ``` 34 | 35 | Mark the project as "Active" for the language packs to be created. The translation strings will continue to be updated with code changes even when the project is set as inactive. 36 | 37 | ## Repository Configuration 38 | 39 | Depending on where your project is hosted, you need to follow different steps to support automatic translation updates. Please read the according documentation: 40 | 41 | * [Bitbucket Repository Configuration](bitbucket.md) 42 | * [GitHub Repository Configuration](github.md) 43 | * [GitLab Repository Configuration](gitlab.md) 44 | * [Self-managed Repository Configuration](self-managed.md) 45 | -------------------------------------------------------------------------------- /docs/self-managed.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Self-managed Repository Configuration 4 | nav_order: 8 5 | --- 6 | 7 | # Self-managed Repository Configuration 8 | 9 | In theory, Traduttore can be adapted to work with any kind of source code repository. 10 | 11 | Out of the box, the software supports self-managed GitLab repositories. Since the setup process for [GitLab.com](https://gitlab.com) and self-managed GitLab instances is similar, please check out the [GitLab Repository Configuration](gitlab.md) section for a thorough introduction. 12 | -------------------------------------------------------------------------------- /inc/CLI/CacheCommand.php: -------------------------------------------------------------------------------- 1 | 32 | * : Project path / ID or source code repository URL, e.g. https://github.com/wearerequired/required-valencia 33 | * 34 | * ## EXAMPLES 35 | * 36 | * # Remove cached repository. 37 | * $ wp traduttore project cache clear https://github.com/wearerequired/required-valencia 38 | * Success: Removed cached Git repository for project (ID: 123)! 39 | * 40 | * # Remove cached repository for given project path. 41 | * $ wp traduttore project cache clear required/required-valencia 42 | * Success: Removed cached Git repository for project (ID: 123)! 43 | * 44 | * # Remove cached repository for given project ID. 45 | * $ wp traduttore project cache clear 123 46 | * Success: Removed cached Git repository for project (ID: 123)! 47 | * 48 | * @since 2.0.0 49 | * 50 | * @param string[] $args Command args. 51 | */ 52 | public function clear( array $args ): void { 53 | $locator = new ProjectLocator( $args[0] ); 54 | $project = $locator->get_project(); 55 | 56 | if ( ! $project ) { 57 | WP_CLI::error( 'Project not found' ); 58 | } 59 | 60 | $repository = ( new RepositoryFactory() )->get_repository( $project ); 61 | 62 | if ( ! $repository ) { 63 | WP_CLI::error( 'Invalid project type' ); 64 | } 65 | 66 | $loader = ( new LoaderFactory() )->get_loader( $repository ); 67 | 68 | if ( ! $loader ) { 69 | WP_CLI::error( 'Invalid project type' ); 70 | } 71 | 72 | $updater = new Updater( $project ); 73 | $runner = new Runner( $loader, $updater ); 74 | 75 | if ( $runner->delete_local_repository() ) { 76 | WP_CLI::success( sprintf( 'Removed cached Git repository for project (ID: %d)!', $project->get_id() ) ); 77 | 78 | return; 79 | } 80 | 81 | WP_CLI::error( sprintf( 'Could not remove cached Git repository for project (ID: %d)!', $project->get_id() ) ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /inc/CLI/CommandNamespace.php: -------------------------------------------------------------------------------- 1 | ] 27 | * : Render output in a particular format. 28 | * --- 29 | * default: list 30 | * options: 31 | * - list 32 | * - json 33 | * --- 34 | * 35 | * ## EXAMPLES 36 | * 37 | * # Display various data about the Traduttore environment 38 | * $ wp traduttore info 39 | * Traduttore version: 3.0.0-alpha 40 | * WordPress version: 4.9.8 41 | * GlotPress version: 2.3.1 42 | * WP-CLI version: 2.0.1 43 | * WP-CLI binary path: /usr/local/bin/wp 44 | * Git binary path: /usr/bin/git 45 | * Mercurial binary path: (not found) 46 | * Subversion binary path: (not found) 47 | * Cache directory: /var/www/network.required.com/wp-content/traduttore 48 | * 49 | * @since 3.0.0 50 | * 51 | * @param string[] $args Command args. 52 | * @param string[] $assoc_args Associative args. 53 | */ 54 | public function __invoke( array $args, array $assoc_args ): void { 55 | $plugin_version = \Required\Traduttore\VERSION; 56 | $wp_version = get_bloginfo( 'version' ); 57 | $gp_version = \defined( 'GP_VERSION' ) ? GP_VERSION : null; 58 | 59 | $wp_cli_version = WP_CLI_VERSION; 60 | $git_binary = $this->get_git_binary_path(); 61 | $hg_binary = $this->get_hg_binary_path(); 62 | $svn_binary = $this->get_svn_binary_path(); 63 | $wp_cli_binary = $this->get_wp_binary_path(); 64 | $cache_dir = ZipProvider::get_cache_dir(); 65 | 66 | if ( get_flag_value( $assoc_args, 'format' ) === 'json' ) { 67 | $info = [ 68 | 'traduttore_version' => $plugin_version, 69 | 'wp_version' => $wp_version, 70 | 'gp_version' => $gp_version, 71 | 'wp_cli_version' => $wp_cli_version, 72 | 'wp_cli_path' => $wp_cli_binary, 73 | 'git_path' => $git_binary, 74 | 'hg_path' => $hg_binary, 75 | 'svn_path' => $svn_binary, 76 | 'cache_dir' => $cache_dir, 77 | ]; 78 | 79 | WP_CLI::line( (string) json_encode( $info ) ); 80 | } else { 81 | WP_CLI::line( "Traduttore version:\t" . $plugin_version ); 82 | WP_CLI::line( "WordPress version:\t" . $wp_version ); 83 | WP_CLI::line( "GlotPress version:\t" . $gp_version ); 84 | WP_CLI::line( "WP-CLI version:\t\t" . $wp_cli_version ); 85 | WP_CLI::line( "WP-CLI binary path:\t" . $wp_cli_binary ); 86 | WP_CLI::line( "Git binary path:\t" . ( $git_binary ?: '(not found)' ) ); 87 | WP_CLI::line( "Mercurial binary path:\t" . ( $hg_binary ?: '(not found)' ) ); 88 | WP_CLI::line( "Subversion binary path:\t" . ( $svn_binary ?: '(not found)' ) ); 89 | WP_CLI::line( "Cache directory:\t" . $cache_dir ); 90 | } 91 | } 92 | 93 | /** 94 | * Returns the path to the Git binary. 95 | * 96 | * @since 3.0.0 97 | * 98 | * @return null|string Binary path on success, null otherwise. 99 | */ 100 | protected function get_git_binary_path(): ?string { 101 | exec( 102 | escapeshellcmd( 'which git' ), 103 | $output, 104 | $status 105 | ); 106 | 107 | return 0 === $status ? $output[0] : null; 108 | } 109 | 110 | /** 111 | * Returns the path to the Mercurial binary. 112 | * 113 | * @since 3.0.0 114 | * 115 | * @return null|string Binary path on success, null otherwise. 116 | */ 117 | protected function get_hg_binary_path(): ?string { 118 | exec( 119 | escapeshellcmd( 'which hg' ), 120 | $output, 121 | $status 122 | ); 123 | 124 | return 0 === $status ? $output[0] : null; 125 | } 126 | 127 | /** 128 | * Returns the path to the Subversion binary. 129 | * 130 | * @since 3.0.0 131 | * 132 | * @return null|string Binary path on success, null otherwise. 133 | */ 134 | protected function get_svn_binary_path(): ?string { 135 | exec( 136 | escapeshellcmd( 'which svn' ), 137 | $output, 138 | $status 139 | ); 140 | 141 | return 0 === $status ? $output[0] : null; 142 | } 143 | 144 | /** 145 | * Returns the path to the WP-CLI binary. 146 | * 147 | * @since 3.0.0 148 | * 149 | * @return null|string Binary path on success, null otherwise. 150 | */ 151 | protected function get_wp_binary_path(): ?string { 152 | if ( \defined( 'TRADUTTORE_WP_BIN' ) && \is_string( TRADUTTORE_WP_BIN ) ) { 153 | return TRADUTTORE_WP_BIN; 154 | } 155 | 156 | exec( 157 | escapeshellcmd( 'which wp' ), 158 | $output, 159 | $status 160 | ); 161 | 162 | return 0 === $status ? $output[0] : null; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /inc/CLI/LanguagePackCommand.php: -------------------------------------------------------------------------------- 1 | 33 | * : Path or ID of the project. 34 | * 35 | * [--field=] 36 | * : Prints the value of a single field for each language pack. 37 | * 38 | * [--fields=] 39 | * : Limit the output to specific language pack fields. 40 | * 41 | * [--format=] 42 | * : Render output in a particular format. 43 | * --- 44 | * default: table 45 | * options: 46 | * - table 47 | * - csv 48 | * - json 49 | * - count 50 | * - yaml 51 | * --- 52 | * 53 | * ## AVAILABLE FIELDS 54 | * 55 | * These fields will be displayed by default for each translation: 56 | * 57 | * * Locale 58 | * * English Name 59 | * * Native Name 60 | * * Completed 61 | * * Updated 62 | * * Package 63 | * 64 | * ## EXAMPLES 65 | * 66 | * # Display available language packs for the given project 67 | * $ wp traduttore language-pack list 1 --fields=Locale,Package 68 | * +--------+----------------------------------------------------------------+ 69 | * | Locale | Package | 70 | * +--------+----------------------------------------------------------------+ 71 | * | fr_FR | https://translate.example.com/content/traduttore/foo-fr_FR.zip | 72 | * | de_DE | https://translate.example.com/content/traduttore/foo-de_DE.zip | 73 | * +--------+----------------------------------------------------------------+ 74 | * 75 | * @since 3.0.0 76 | * 77 | * @param string[] $args Command args. 78 | * @param string[] $assoc_args Associative args. 79 | */ 80 | public function list( array $args, array $assoc_args ): void { 81 | $locator = new ProjectLocator( $args[0] ); 82 | $project = $locator->get_project(); 83 | 84 | if ( ! $project ) { 85 | WP_CLI::error( 'Project not found' ); 86 | } 87 | 88 | $translation_sets = (array) GP::$translation_set->by_project_id( $project->get_id() ); 89 | 90 | $language_packs = []; 91 | 92 | /** @var \GP_Translation_Set $set */ 93 | foreach ( $translation_sets as $set ) { 94 | /** @var \GP_Locale $locale */ 95 | $locale = GP_Locales::by_slug( $set->locale ); 96 | 97 | $zip_provider = new ZipProvider( $set ); 98 | 99 | $last_updated = $zip_provider->get_last_build_time(); 100 | 101 | $language_packs[] = [ 102 | 'Locale' => $locale->wp_locale, 103 | 'English Name' => $locale->english_name, 104 | 'Native Name' => $locale->native_name, 105 | 'Completed' => sprintf( '%s%%', $set->percent_translated() ), 106 | 'Updated' => $last_updated ? $last_updated->format( DATE_ATOM ) : 'n/a', 107 | 'Package' => file_exists( $zip_provider->get_zip_path() ) ? $zip_provider->get_zip_url() : 'n/a', 108 | ]; 109 | } 110 | 111 | $formatter = new WP_CLI\Formatter( 112 | $assoc_args, 113 | [ 114 | 'Locale', 115 | 'English Name', 116 | 'Native Name', 117 | 'Completed', 118 | 'Updated', 119 | 'Package', 120 | ] 121 | ); 122 | 123 | $formatter->display_items( $language_packs ); 124 | } 125 | 126 | /** 127 | * Generate language packs for one or more projects. 128 | * 129 | * ## OPTIONS 130 | * 131 | * [...] 132 | * : One or more project paths or IDs. 133 | * 134 | * [--force] 135 | * : Force language pack generation, even if there were no changes since the last build. 136 | * 137 | * [--all] 138 | * : If set, language packs will be generated for all projects. 139 | * 140 | * ## EXAMPLES 141 | * 142 | * # Generate language packs for the project with ID 123. 143 | * $ wp traduttore language-pack build 123 144 | * Language pack generated for translation set (ID: 1) 145 | * Language pack generated for translation set (ID: 3) 146 | * Language pack generated for translation set (ID: 7) 147 | * Success: Language pack generation finished 148 | * 149 | * # Generate language packs for all projects. 150 | * $ wp traduttore language-pack build --all 151 | * Language pack generated for translation set (ID: 1) 152 | * Language pack generated for translation set (ID: 2) 153 | * Language pack generated for translation set (ID: 3) 154 | * Language pack generated for translation set (ID: 4) 155 | * Language pack generated for translation set (ID: 5) 156 | * Language pack generated for translation set (ID: 7) 157 | * Success: Language pack generation finished 158 | * 159 | * @since 3.0.0 160 | * 161 | * @param string[] $args Command args. 162 | * @param string[] $assoc_args Associative args. 163 | */ 164 | public function build( array $args, array $assoc_args ): void { 165 | $all = (bool) get_flag_value( $assoc_args, 'all', false ); 166 | $force = (bool) get_flag_value( $assoc_args, 'force', false ); 167 | $projects = $this->check_optional_args_and_all( $args, $all ); 168 | 169 | if ( ! $projects ) { 170 | return; 171 | } 172 | 173 | foreach ( $projects as $project ) { 174 | $translation_sets = (array) GP::$translation_set->by_project_id( $project->get_id() ); 175 | 176 | /** @var \GP_Translation_Set $translation_set */ 177 | foreach ( $translation_sets as $translation_set ) { 178 | if ( 0 === $translation_set->current_count() ) { 179 | WP_CLI::log( sprintf( 'No language pack generated for translation set as there are no entries (ID: %d)', $translation_set->id ) ); 180 | 181 | continue; 182 | } 183 | 184 | $zip_provider = new ZipProvider( $translation_set ); 185 | $last_modified = $translation_set->last_modified(); 186 | 187 | if ( $last_modified ) { 188 | $last_modified = new DateTime( $last_modified, new DateTimeZone( 'UTC' ) ); 189 | } else { 190 | $last_modified = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); 191 | } 192 | 193 | if ( ! $force && $last_modified <= $zip_provider->get_last_build_time() ) { 194 | WP_CLI::log( sprintf( 'No language pack generated for translation set as there were no changes (ID: %d)', $translation_set->id ) ); 195 | 196 | continue; 197 | } 198 | 199 | if ( $zip_provider->generate_zip_file() ) { 200 | WP_CLI::log( sprintf( 'Language pack generated for translation set (ID: %d)', $translation_set->id ) ); 201 | 202 | continue; 203 | } 204 | 205 | WP_CLI::warning( sprintf( 'Error generating Language pack for translation set (ID: %d)', $translation_set->id ) ); 206 | } 207 | } 208 | 209 | WP_CLI::success( 'Language pack generation finished' ); 210 | } 211 | 212 | /** 213 | * If there are optional args ([...]) and an all option, then check if we have something to do. 214 | * 215 | * @since 3.0.0 216 | * 217 | * @param string[] $args Passed arguments. 218 | * @param bool $all All flag. 219 | * @return \Required\Traduttore\Project[] List of projects based off args. 220 | */ 221 | protected function check_optional_args_and_all( array $args, bool $all ): array { 222 | if ( empty( $args ) ) { 223 | if ( ! $all ) { 224 | WP_CLI::error( 'Please specify one or more projects, or use --all.' ); 225 | } 226 | 227 | WP_CLI::success( 'No projects found' ); 228 | } 229 | 230 | $projects = []; 231 | if ( $all ) { 232 | $projects = $this->get_all_projects(); 233 | } 234 | 235 | $projects = array_map( 236 | function ( $project_id ) { 237 | $project = ( new ProjectLocator( $project_id ) )->get_project(); 238 | if ( ! $project ) { 239 | WP_CLI::log( sprintf( 'Project (ID: %d) does not exist.', $project_id ) ); 240 | return null; 241 | } 242 | if ( ! $project->is_active() ) { 243 | WP_CLI::log( sprintf( 'Project (ID: %d) is inactive.', $project->get_id() ) ); 244 | return null; 245 | } 246 | return $project; 247 | }, 248 | $args 249 | ); 250 | 251 | // Remove non-existent projects. 252 | $projects = array_filter( $projects ); 253 | 254 | return $projects; 255 | } 256 | 257 | /** 258 | * Returns all active GlotPress projects. 259 | * 260 | * @since 3.0.0 261 | * 262 | * @return \GP_Thing[] GlotPress projects 263 | */ 264 | protected function get_all_projects(): array { 265 | return GP::$project->many( GP::$project->select_all_from_conditions_and_order( [ 'active' => 1 ] ) ); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /inc/CLI/ProjectCommand.php: -------------------------------------------------------------------------------- 1 | 31 | * : Path or ID of the project. 32 | * 33 | * [--format=] 34 | * : Render output in a particular format. 35 | * --- 36 | * default: list 37 | * options: 38 | * - list 39 | * - json 40 | * --- 41 | * 42 | * ## EXAMPLES 43 | * 44 | * # Display various data about the project 45 | * $ wp traduttore project info foo 46 | * Project ID: 1 47 | * Project name: Foo Project 48 | * Project slug: foo 49 | * Version: 1.0.1 50 | * Text domain: foo-plugin 51 | * Last updated: 2018-11-11 11:11:11 52 | * Repository Cache: /tmp/traduttore-github.com-wearerequired-foo 53 | * Repository URL: (unknown) 54 | * Repository Type: github 55 | * Repository VCS Type: (unknown) 56 | * Repository Visibility: private 57 | * Repository SSH URL: git@github.com:wearerequired/foo.git 58 | * Repository HTTPS URL: https://github.com/wearerequired/foo.git 59 | * Repository Instance: Required\Traduttore\Repository\GitHub 60 | * Loader Instance: Required\Traduttore\Loader\Git 61 | * 62 | * @since 3.0.0 63 | * 64 | * @param string[] $args Command args. 65 | * @param string[] $assoc_args Associative args. 66 | */ 67 | public function info( array $args, array $assoc_args ): void { 68 | $locator = new ProjectLocator( $args[0] ); 69 | $project = $locator->get_project(); 70 | 71 | if ( ! $project ) { 72 | WP_CLI::error( 'Project not found' ); 73 | } 74 | 75 | $repository = ( new RepositoryFactory() )->get_repository( $project ); 76 | $loader = $repository ? ( new LoaderFactory() )->get_loader( $repository ) : null; 77 | 78 | $project_id = $project->get_id(); 79 | $project_name = $project->get_name(); 80 | $project_slug = $project->get_slug(); 81 | $project_version = $project->get_version(); 82 | $project_text_domain = $project->get_text_domain(); 83 | $last_updated = $project->get_last_updated_time() ? $project->get_last_updated_time()->format( DATE_ATOM ) : ''; 84 | $local_path = $loader ? $loader->get_local_path() : ''; 85 | $repository_url = $project->get_repository_url() ?? '(unknown)'; 86 | $repository_type = $repository ? $repository->get_type() : $project->get_repository_type(); 87 | $repository_vcs_type = $project->get_repository_vcs_type() ?? '(unknown)'; 88 | $repository_visibility = $project->get_repository_visibility() ?? '(unknown)'; 89 | $repository_ssh_url = $repository ? $repository->get_ssh_url() : '(unknown)'; 90 | $repository_https_url = $repository ? $repository->get_https_url() : '(unknown)'; 91 | $repository_instance = $repository ? $repository::class : '(unknown)'; 92 | $loader_instance = $loader ? $loader::class : '(unknown)'; 93 | 94 | if ( get_flag_value( $assoc_args, 'format' ) === 'json' ) { 95 | $info = [ 96 | 'id' => $project_id, 97 | 'name' => $project_name, 98 | 'slug' => $project_slug, 99 | 'version' => $project_version, 100 | 'text_domain' => $project_text_domain, 101 | 'last_updated' => $last_updated, 102 | 'repository_cache' => $local_path, 103 | 'repository_url' => $repository_url, 104 | 'repository_type' => $repository_type, 105 | 'repository_vcs_type' => $repository_vcs_type, 106 | 'repository_visibility' => $repository_visibility, 107 | 'repository_ssh_url' => $repository_ssh_url, 108 | 'repository_https_url' => $repository_https_url, 109 | 'repository_instance' => $repository_instance, 110 | 'loader_instance' => $loader_instance, 111 | ]; 112 | 113 | WP_CLI::line( (string) json_encode( $info ) ); 114 | } else { 115 | WP_CLI::line( "Project ID:\t\t" . $project_id ); 116 | WP_CLI::line( "Project name:\t\t" . $project_name ); 117 | WP_CLI::line( "Project slug:\t\t" . $project_slug ); 118 | WP_CLI::line( "Version:\t\t" . $project_version ); 119 | WP_CLI::line( "Text domain:\t\t" . $project_text_domain ); 120 | WP_CLI::line( "Last updated:\t\t" . $last_updated ); 121 | WP_CLI::line( "Repository Cache:\t" . $local_path ); 122 | WP_CLI::line( "Repository URL:\t\t" . $repository_url ); 123 | WP_CLI::line( "Repository Type:\t" . $repository_type ); 124 | WP_CLI::line( "Repository VCS Type:\t" . $repository_vcs_type ); 125 | WP_CLI::line( "Repository Visibility:\t" . $repository_visibility ); 126 | WP_CLI::line( "Repository SSH URL:\t" . $repository_ssh_url ); 127 | WP_CLI::line( "Repository HTTPS URL:\t" . $repository_https_url ); 128 | WP_CLI::line( "Repository Instance:\t" . $repository_instance ); 129 | WP_CLI::line( "Loader Instance:\t" . $loader_instance ); 130 | } 131 | } 132 | 133 | /** 134 | * Updates project translations from source code repository. 135 | * 136 | * Finds the project the repository belongs to and updates the translations accordingly. 137 | * 138 | * ## OPTIONS 139 | * 140 | * 141 | * : Project path / ID or source code repository URL, e.g. https://github.com/wearerequired/required-valencia 142 | * 143 | * [--cached] 144 | * : Use cached repository information and do not try to download code from remote. 145 | * 146 | * [--delete] 147 | * : Whether to first delete the existing local repository or not. 148 | * 149 | * ## EXAMPLES 150 | * 151 | * # Update translations from repository URL. 152 | * $ wp traduttore project update https://github.com/wearerequired/required-valencia 153 | * Success: Updated translations for project (ID: 123)! 154 | * 155 | * # Update translations from project path. 156 | * $ wp traduttore project update required/required-valencia 157 | * Success: Updated translations for project (ID: 123)! 158 | * 159 | * # Update translations from project ID. 160 | * $ wp traduttore project update 123 161 | * Success: Updated translations for project (ID: 123)! 162 | * 163 | * @since 3.0.0 164 | * 165 | * @param string[] $args Command args. 166 | * @param string[] $assoc_args Associative args. 167 | */ 168 | public function update( array $args, array $assoc_args ): void { 169 | $delete = (bool) get_flag_value( $assoc_args, 'delete', false ); 170 | $cached = (bool) get_flag_value( $assoc_args, 'cached', false ); 171 | $locator = new ProjectLocator( $args[0] ); 172 | $project = $locator->get_project(); 173 | 174 | if ( ! $project ) { 175 | WP_CLI::error( 'Project not found' ); 176 | } 177 | 178 | $repository = ( new RepositoryFactory() )->get_repository( $project ); 179 | 180 | if ( ! $repository ) { 181 | WP_CLI::error( 'Invalid project type' ); 182 | } 183 | 184 | $loader = ( new LoaderFactory() )->get_loader( $repository ); 185 | 186 | if ( ! $loader ) { 187 | WP_CLI::error( 'Invalid project type' ); 188 | } 189 | 190 | $updater = new Updater( $project ); 191 | 192 | $runner = new Runner( $loader, $updater ); 193 | 194 | if ( $delete ) { 195 | $runner->delete_local_repository(); 196 | } 197 | 198 | $success = $runner->run( $cached ); 199 | 200 | if ( $success ) { 201 | WP_CLI::success( sprintf( 'Updated translations for project (ID: %d)!', $project->get_id() ) ); 202 | 203 | return; 204 | } 205 | 206 | WP_CLI::warning( sprintf( 'Could not update translations for project (ID: %d)!', $project->get_id() ) ); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /inc/Configuration.php: -------------------------------------------------------------------------------- 1 | path = $path; 47 | 48 | $this->config = $this->load_config(); 49 | } 50 | 51 | /** 52 | * Returns the repository path. 53 | * 54 | * @since 3.0.0 55 | * 56 | * @return string $path Repository path. 57 | */ 58 | public function get_path(): string { 59 | return $this->path; 60 | } 61 | 62 | /** 63 | * Returns the configuration array. 64 | * 65 | * @since 3.0.0 66 | * 67 | * @return array Repository configuration. 68 | * 69 | * @phpstan-return ProjectConfig 70 | */ 71 | public function get_config(): array { 72 | return $this->config; 73 | } 74 | 75 | /** 76 | * Returns a single config. 77 | * 78 | * @since 3.0.0 79 | * 80 | * @param string $key Config key. 81 | * @return string|string[]|null Config value. 82 | * 83 | * @phpstan-template T of key-of 84 | * @phpstan-param T $key 85 | * @phpstan-return ProjectConfig[T] | null 86 | */ 87 | public function get_config_value( string $key ): mixed { 88 | if ( isset( $this->config[ $key ] ) ) { 89 | return $this->config[ $key ]; 90 | } 91 | 92 | return null; 93 | } 94 | 95 | /** 96 | * Loads the configuration for the current path. 97 | * 98 | * @since 3.0.0 99 | * 100 | * @return array Configuration data if found. 101 | * 102 | * @phpstan-return ProjectConfig 103 | */ 104 | protected function load_config(): array { 105 | $config_file = trailingslashit( $this->path ) . 'traduttore.json'; 106 | $composer_file = trailingslashit( $this->path ) . 'composer.json'; 107 | 108 | if ( file_exists( $config_file ) ) { 109 | /** 110 | * Traduttore configuration. 111 | * 112 | * @phpstan-var ProjectConfig $config 113 | * @var array $config 114 | */ 115 | $config = json_decode( (string) file_get_contents( $config_file ), true ); 116 | 117 | if ( $config ) { 118 | return $config; 119 | } 120 | } 121 | 122 | if ( file_exists( $composer_file ) ) { 123 | /** 124 | * Composer configuration. 125 | * 126 | * @phpstan-var array{extra?: array{ traduttore?: ProjectConfig } } $config 127 | * @var array{extra?: array{ traduttore?: array } } $config 128 | */ 129 | $config = json_decode( (string) file_get_contents( $composer_file ), true ); 130 | 131 | if ( $config && isset( $config['extra']['traduttore'] ) ) { 132 | return $config['extra']['traduttore']; 133 | } 134 | } 135 | 136 | return []; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /inc/Export.php: -------------------------------------------------------------------------------- 1 | translation_set = $translation_set; 63 | $this->locale = GP_Locales::by_slug( $translation_set->locale ); 64 | 65 | /** 66 | * GlotPress project. 67 | * 68 | * @var \GP_Project $gp_project 69 | */ 70 | $gp_project = GP::$project->get( $translation_set->project_id ); 71 | 72 | $this->project = new Project( $gp_project ); 73 | } 74 | 75 | /** 76 | * Saves strings to different file formats and returns a list of generated files. 77 | * 78 | * @since 3.0.0 79 | * 80 | * @return array List of files with names as key and temporary file location as value. 81 | */ 82 | public function export_strings(): ?array { 83 | 84 | /** 85 | * Filters the status of the entries to export. 86 | * 87 | * @since 4.0.0 88 | * 89 | * @param string $export_status The status of the entries to export. Default is 'current'. 90 | * @param \GP_Translation_Set $translation_set Translation set the language pack is for. 91 | * @param \Required\Traduttore\Project $project The project that was updated. 92 | */ 93 | $export_status = apply_filters( 'traduttore.export_status', 'current', $this->translation_set, $this->project ); 94 | 95 | $entries = GP::$translation->for_export( $this->project->get_project(), $this->translation_set, [ 'status' => $export_status ] ); 96 | 97 | if ( ! $entries ) { 98 | return null; 99 | } 100 | 101 | // Build a mapping based on where the translation entries occur and separate the po entries. 102 | 103 | $mapping = $this->map_entries_to_source( $entries ); 104 | 105 | $php_entries = \array_key_exists( 'php', $mapping ) ? $mapping['php'] : []; 106 | 107 | unset( $mapping['php'] ); 108 | 109 | $this->build_json_files( $mapping ); 110 | $this->build_po_file( $php_entries ); 111 | $this->build_mo_file( $php_entries ); 112 | $this->build_php_file( $php_entries ); 113 | 114 | return $this->files; 115 | } 116 | 117 | /** 118 | * Writes content to a file using the WordPress Filesystem Abstraction interface. 119 | * 120 | * @since 3.0.0 121 | * 122 | * @param string $file File path. 123 | * @param string $contents File contents. 124 | * @return bool True on success, false otherwise. 125 | */ 126 | protected function write_to_file( string $file, string $contents ): bool { 127 | /** @var \WP_Filesystem_Base|null $wp_filesystem */ 128 | global $wp_filesystem; 129 | 130 | if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { 131 | require_once ABSPATH . '/wp-admin/includes/admin.php'; 132 | 133 | if ( ! \WP_Filesystem() ) { 134 | return false; 135 | } 136 | } 137 | 138 | if ( ! $wp_filesystem ) { 139 | return false; 140 | } 141 | 142 | return $wp_filesystem->put_contents( $file, $contents, FS_CHMOD_FILE ); 143 | } 144 | 145 | /** 146 | * Returns the base name for translation files. 147 | * 148 | * @since 3.0.0 149 | * 150 | * @return string Base file name without extension. 151 | */ 152 | protected function get_base_file_name(): string { 153 | $slug = $this->project->get_slug(); 154 | $text_domain = $this->project->get_text_domain(); 155 | 156 | if ( $text_domain ) { 157 | $slug = $text_domain; 158 | } 159 | 160 | return "{$slug}-{$this->locale->wp_locale}"; 161 | } 162 | 163 | /** 164 | * Build a mapping of JS files to translation entries occurring in those files. 165 | * 166 | * Translation entries occurring in other files are added to the 'php' key. 167 | * 168 | * @since 3.0.0 169 | * 170 | * @param \Translation_Entry[] $entries The translation entries to map. 171 | * @return array The mapping of sources to translation entries. 172 | */ 173 | protected function map_entries_to_source( array $entries ): array { 174 | $mapping = []; 175 | 176 | foreach ( $entries as $entry ) { 177 | // Find all unique sources this translation originates from. 178 | if ( ! empty( $entry->references ) ) { 179 | $sources = array_map( 180 | function ( $reference ) { 181 | $parts = explode( ':', $reference ); 182 | $file = $parts[0]; 183 | 184 | if ( substr( $file, -7 ) === '.min.js' ) { 185 | return substr( $file, 0, -7 ) . '.js'; 186 | } 187 | 188 | if ( substr( $file, -3 ) === '.js' ) { 189 | return $file; 190 | } 191 | 192 | return 'php'; 193 | }, 194 | $entry->references 195 | ); 196 | 197 | $sources = array_unique( $sources ); 198 | } else { 199 | $sources = [ 'php' ]; 200 | } 201 | 202 | foreach ( $sources as $source ) { 203 | $mapping[ $source ][] = $entry; 204 | } 205 | } 206 | 207 | /** 208 | * Filters the mapping of sources to translation entries. 209 | * 210 | * @since 3.1.0 211 | * 212 | * @param array $mapping The mapping of sources to translation entries. 213 | * @param \Translation_Entry[] $entries The translation entries to map. 214 | * @param \Required\Traduttore\Project $project The project that is exported. 215 | */ 216 | return (array) apply_filters( 'traduttore.map_entries_to_source', $mapping, $entries, $this->project ); 217 | } 218 | 219 | /** 220 | * Builds a mapping of JS file names to translation entries. 221 | * 222 | * Exports translations for each JS file to a separate translation file. 223 | * 224 | * @since 3.0.0 225 | * 226 | * @param array $mapping A mapping of files to translation entries. 227 | */ 228 | protected function build_json_files( array $mapping ): void { 229 | /** @var \GP_Format $format */ 230 | $format = gp_array_get( GP::$formats, 'jed1x' ); 231 | 232 | $base_file_name = $this->get_base_file_name(); 233 | 234 | foreach ( $mapping as $file => $entries ) { 235 | // Don't create JSON files for source files. 236 | if ( 0 === strpos( $file, 'src/' ) || false !== strpos( $file, '/src/' ) ) { 237 | continue; 238 | } 239 | 240 | $contents = $format->print_exported_file( $this->project->get_project(), $this->locale, $this->translation_set, $entries ); 241 | 242 | // Add comment with file reference for debugging. 243 | $contents_decoded = (object) json_decode( $contents ); 244 | $contents_decoded->comment = [ 'reference' => $file ]; 245 | $contents = (string) wp_json_encode( $contents_decoded ); 246 | 247 | $hash = md5( $file ); 248 | $file_name = "{$base_file_name}-{$hash}.json"; 249 | $temp_file = wp_tempnam( $file_name ); 250 | 251 | if ( $this->write_to_file( $temp_file, $contents ) ) { 252 | $this->files[ $file_name ] = $temp_file; 253 | } 254 | } 255 | } 256 | 257 | /** 258 | * Builds a PO file for translations. 259 | * 260 | * @since 3.0.0 261 | * 262 | * @param \Translation_Entry[] $entries The translation entries. 263 | */ 264 | protected function build_po_file( array $entries ): void { 265 | /** @var \GP_Format $format */ 266 | $format = gp_array_get( GP::$formats, 'po' ); 267 | 268 | $base_file_name = $this->get_base_file_name(); 269 | $file_name = "{$base_file_name}.po"; 270 | $temp_file = wp_tempnam( $file_name ); 271 | 272 | $contents = $format->print_exported_file( $this->project->get_project(), $this->locale, $this->translation_set, $entries ); 273 | 274 | if ( $this->write_to_file( $temp_file, $contents ) ) { 275 | $this->files[ $file_name ] = $temp_file; 276 | } 277 | } 278 | 279 | /** 280 | * Builds a MO file for translations. 281 | * 282 | * @since 3.0.0 283 | * 284 | * @param \Translation_Entry[] $entries The translation entries. 285 | */ 286 | protected function build_mo_file( array $entries ): void { 287 | /** @var \GP_Format $format */ 288 | $format = gp_array_get( GP::$formats, 'mo' ); 289 | 290 | $base_file_name = $this->get_base_file_name(); 291 | $file_name = "{$base_file_name}.mo"; 292 | $temp_file = wp_tempnam( $file_name ); 293 | 294 | $contents = $format->print_exported_file( $this->project->get_project(), $this->locale, $this->translation_set, $entries ); 295 | 296 | if ( $this->write_to_file( $temp_file, $contents ) ) { 297 | $this->files[ $file_name ] = $temp_file; 298 | } 299 | } 300 | 301 | /** 302 | * Builds a PHP file for translations. 303 | * 304 | * @since 3.3.0 305 | * 306 | * @param \Translation_Entry[] $entries The translation entries. 307 | */ 308 | protected function build_php_file( array $entries ): void { 309 | /** @var \GP_Format $format */ 310 | $format = gp_array_get( GP::$formats, 'php' ); 311 | 312 | $base_file_name = $this->get_base_file_name(); 313 | $file_name = "{$base_file_name}.l10n.php"; 314 | $temp_file = wp_tempnam( $file_name ); 315 | 316 | $contents = $format->print_exported_file( $this->project->get_project(), $this->locale, $this->translation_set, $entries ); 317 | 318 | if ( $this->write_to_file( $temp_file, $contents ) ) { 319 | $this->files[ $file_name ] = $temp_file; 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /inc/Loader.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 35 | } 36 | 37 | /** 38 | * Returns the path to where the Git repository should be checked out. 39 | * 40 | * @since 3.0.0 41 | * 42 | * @return string Git repository path. 43 | */ 44 | public function get_local_path(): string { 45 | return sprintf( 46 | '%1$straduttore-%2$s-%3$s', 47 | get_temp_dir(), 48 | $this->repository->get_host(), 49 | sanitize_title( $this->repository->get_name() ?? '' ) 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /inc/Loader/Git.php: -------------------------------------------------------------------------------- 1 | get_local_path(); 27 | 28 | if ( is_dir( $target ) ) { 29 | $current_dir = getcwd(); 30 | chdir( $target ); 31 | exec( escapeshellcmd( 'git reset --hard -q' ), $output, $status ); 32 | exec( escapeshellcmd( 'git pull -q' ), $output, $status ); 33 | if ( $current_dir ) { 34 | chdir( $current_dir ); 35 | } 36 | 37 | return 0 === $status ? $target : null; 38 | } 39 | 40 | exec( 41 | escapeshellcmd( 42 | sprintf( 43 | 'git clone --depth=1 %1$s %2$s -q', 44 | escapeshellarg( $this->get_clone_url() ), 45 | escapeshellarg( $target ) 46 | ) 47 | ), 48 | $output, 49 | $status 50 | ); 51 | 52 | return 0 === $status ? $target : null; 53 | } 54 | 55 | /** 56 | * Returns the URL to clone the current repository. 57 | * 58 | * Supports HTTPS and SSH URLs. 59 | * 60 | * @since 3.0.0 61 | * 62 | * @return string URL to clone the repository, e.g. git@github.com:wearerequired/traduttore.git 63 | * or https://github.com/wearerequired/traduttore.git. 64 | */ 65 | protected function get_clone_url(): string { 66 | /** 67 | * Filters whether HTTPS or SSH should be used to clone a repository. 68 | * 69 | * @since 3.0.0 70 | * 71 | * @param bool $use_https Whether to use HTTPS instead of SSH for 72 | * cloning repositories. 73 | * Defaults to true for public repositories. 74 | * @param \Required\Traduttore\Repository $repository The current repository. 75 | */ 76 | $use_https = apply_filters( 'traduttore.git_clone_use_https', $this->repository->is_public(), $this->repository ); 77 | 78 | $clone_url = $this->repository->get_ssh_url(); 79 | 80 | if ( $use_https ) { 81 | $clone_url = $this->repository->get_https_url(); 82 | } 83 | 84 | /** 85 | * Filters the URL used to clone a Git repository. 86 | * 87 | * @since 3.0.0 88 | * 89 | * @param string $clone_url The URL to clone a Git repository. 90 | * @param \Required\Traduttore\Repository $repository The current repository. 91 | */ 92 | return apply_filters( 'traduttore.git_clone_url', (string) $clone_url, $this->repository ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /inc/Loader/Mercurial.php: -------------------------------------------------------------------------------- 1 | get_local_path(); 27 | 28 | if ( is_dir( $target ) ) { 29 | $current_dir = getcwd(); 30 | chdir( $target ); 31 | exec( escapeshellcmd( 'hg update --clean -q' ), $output, $status ); 32 | exec( escapeshellcmd( 'hg pull -q' ), $output, $status ); 33 | if ( $current_dir ) { 34 | chdir( $current_dir ); 35 | } 36 | 37 | return 0 === $status ? $target : null; 38 | } 39 | 40 | exec( 41 | escapeshellcmd( 42 | sprintf( 43 | 'hg clone %1$s %2$s -q', 44 | escapeshellarg( $this->get_clone_url() ), 45 | escapeshellarg( $target ) 46 | ) 47 | ), 48 | $output, 49 | $status 50 | ); 51 | 52 | return 0 === $status ? $target : null; 53 | } 54 | 55 | /** 56 | * Returns the URL to clone the current repository. 57 | * 58 | * Supports HTTPS and SSH URLs. 59 | * 60 | * @since 3.0.0 61 | * 62 | * @return string URL to clone the repository, e.g. hg@bitbucket.org/wearerequired/traduttore 63 | * or https://bitbucket.org/wearerequired/traduttore. 64 | */ 65 | protected function get_clone_url(): string { 66 | /** 67 | * Filters whether HTTPS or SSH should be used to clone a repository. 68 | * 69 | * @since 3.0.0 70 | * 71 | * @param bool $use_https Whether to use HTTPS instead of SSH for 72 | * cloning repositories. 73 | * Defaults to true for public repositories. 74 | * @param \Required\Traduttore\Repository $repository The current repository. 75 | */ 76 | $use_https = apply_filters( 'traduttore.hg_clone_use_https', $this->repository->is_public(), $this->repository ); 77 | 78 | $clone_url = $this->repository->get_ssh_url(); 79 | 80 | if ( $use_https ) { 81 | $clone_url = $this->repository->get_https_url(); 82 | } 83 | 84 | /** 85 | * Filters the URL used to clone a Mercurial repository. 86 | * 87 | * @since 3.0.0 88 | * 89 | * @param string $clone_url The URL to clone a Mercurial repository. 90 | * @param \Required\Traduttore\Repository $repository The current repository. 91 | */ 92 | return apply_filters( 'traduttore.hg_clone_url', (string) $clone_url, $this->repository ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /inc/Loader/Subversion.php: -------------------------------------------------------------------------------- 1 | get_local_path() ) ) { 27 | $this->update_existing_repository(); 28 | } 29 | 30 | return $this->download_new_repository(); 31 | } 32 | 33 | /** 34 | * Downloads a fresh copy of a remote repository. 35 | * 36 | * @since 3.0.0 37 | * 38 | * @return null|string Path to the downloaded repository on success. 39 | */ 40 | protected function download_new_repository(): ?string { 41 | $target = $this->get_local_path(); 42 | 43 | exec( 44 | escapeshellcmd( 45 | sprintf( 46 | 'svn checkout %1$s %2$s -q', 47 | escapeshellarg( $this->get_checkout_url() ), 48 | escapeshellarg( $target ) 49 | ) 50 | ), 51 | $output, 52 | $status 53 | ); 54 | 55 | return 0 === $status ? $target : null; 56 | } 57 | 58 | /** 59 | * Updates an existing copy of a remote repository. 60 | * 61 | * @since 3.0.0 62 | * 63 | * @return null|string Path to the downloaded repository on success. 64 | */ 65 | protected function update_existing_repository(): ?string { 66 | $target = $this->get_local_path(); 67 | 68 | $current_dir = getcwd(); 69 | chdir( $target ); 70 | exec( escapeshellcmd( 'svn revert --recursive .' ), $output, $status ); 71 | exec( escapeshellcmd( 'svn update .' ), $output, $status ); 72 | if ( $current_dir ) { 73 | chdir( $current_dir ); 74 | } 75 | 76 | return 0 === $status ? $target : null; 77 | } 78 | 79 | /** 80 | * Returns the URL to check out the current repository. 81 | * 82 | * Supports HTTPS and SSH URLs. 83 | * 84 | * @since 3.0.0 85 | * 86 | * @return string URL to check out the repository, e.g. svn+ssh://svn.example.com/wearerequired/traduttore 87 | * or https://svn.example.com/wearerequired/traduttore. 88 | */ 89 | protected function get_checkout_url(): string { 90 | /** 91 | * Filters whether HTTPS or SSH should be used to check out a Subversion repository. 92 | * 93 | * @since 3.0.0 94 | * 95 | * @param bool $use_https Whether to use HTTPS instead of SSH for 96 | * checking out Subversion repositories. 97 | * Defaults to true for public repositories. 98 | * @param \Required\Traduttore\Repository $repository The current repository. 99 | */ 100 | $use_https = apply_filters( 'traduttore.svn_checkout_use_https', $this->repository->is_public(), $this->repository ); 101 | 102 | $checkout_url = $this->repository->get_ssh_url(); 103 | 104 | if ( $use_https ) { 105 | $checkout_url = $this->repository->get_https_url(); 106 | } 107 | 108 | /** 109 | * Filters the URL used to check out a Subversion repository. 110 | * 111 | * @since 3.0.0 112 | * 113 | * @param string $checkout_url The URL to check out a Subversion repository. 114 | * @param \Required\Traduttore\Repository $repository The current repository. 115 | */ 116 | return apply_filters( 'traduttore.svn_checkout_url', (string) $checkout_url, $this->repository ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /inc/LoaderFactory.php: -------------------------------------------------------------------------------- 1 | get_project()->get_repository_vcs_type() ) { 31 | $loader = new MercurialLoader( $repository ); 32 | } elseif ( \in_array( 33 | $repository->get_type(), 34 | [ 35 | Repository::TYPE_BITBUCKET, 36 | Repository::TYPE_GITHUB, 37 | Repository::TYPE_GITLAB, 38 | ], 39 | true 40 | ) ) { 41 | $loader = new GitLoader( $repository ); 42 | } 43 | 44 | /** 45 | * Filters the loader instance for a given repository and project. 46 | * 47 | * @param \Required\Traduttore\Loader|null $loader Loader instance. 48 | * @param \Required\Traduttore\Repository|null $repository Repository instance. 49 | */ 50 | return apply_filters( 'traduttore.loader', $loader, $repository ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /inc/Plugin.php: -------------------------------------------------------------------------------- 1 | register_hooks(); 34 | } 35 | 36 | /** 37 | * Registers actions and filters. 38 | * 39 | * @since 1.0.0 40 | */ 41 | public function register_hooks(): void { 42 | add_action( 'init', [ $this, 'setup_translations' ] ); 43 | 44 | add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); 45 | 46 | add_action( 'gp_init', [ $this, 'register_glotpress_api_routes' ] ); 47 | 48 | add_action( 49 | 'gp_translation_saved', 50 | static function ( GP_Translation $translation ): void { 51 | /** @var \GP_Translation_Set $translation_set */ 52 | $translation_set = GP::$translation_set->get( $translation->translation_set_id ); 53 | 54 | $project = ( new ProjectLocator( $translation_set->project_id ) )->get_project(); 55 | if ( ! $project || ! $project->is_active() ) { 56 | return; 57 | } 58 | 59 | $last_modified = $translation_set->last_modified(); 60 | if ( $last_modified ) { 61 | $last_modified = new DateTime( $last_modified, new DateTimeZone( 'UTC' ) ); 62 | } else { 63 | $last_modified = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); 64 | } 65 | 66 | $zip_provider = new ZipProvider( $translation_set ); 67 | if ( $last_modified > $zip_provider->get_last_build_time() ) { 68 | $zip_provider->schedule_generation(); 69 | } 70 | } 71 | ); 72 | 73 | add_action( 74 | 'gp_originals_imported', 75 | static function ( $project_id, $originals_added, $originals_existing, $originals_obsoleted, $originals_fuzzied ): void { 76 | $project = ( new ProjectLocator( $project_id ) )->get_project(); 77 | 78 | if ( ! $project || ! $project->is_active() ) { 79 | return; 80 | } 81 | 82 | if ( 0 === max( $originals_existing, $originals_obsoleted, $originals_fuzzied ) ) { 83 | return; 84 | } 85 | 86 | $translation_sets = (array) GP::$translation_set->by_project_id( $project->get_id() ); 87 | 88 | /** @var \GP_Translation_Set $translation_set */ 89 | foreach ( $translation_sets as $translation_set ) { 90 | $last_modified = $translation_set->last_modified(); 91 | if ( $last_modified ) { 92 | $last_modified = new DateTime( $last_modified, new DateTimeZone( 'UTC' ) ); 93 | } else { 94 | $last_modified = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); 95 | } 96 | 97 | $zip_provider = new ZipProvider( $translation_set ); 98 | if ( $last_modified > $zip_provider->get_last_build_time() ) { 99 | $zip_provider->schedule_generation(); 100 | } 101 | } 102 | }, 103 | 10, 104 | 5 105 | ); 106 | 107 | add_action( 108 | 'traduttore.generate_zip', 109 | static function ( $translation_set_id ): void { 110 | /** @var \GP_Translation_Set $translation_set */ 111 | $translation_set = GP::$translation_set->get( $translation_set_id ); 112 | 113 | $zip_provider = new ZipProvider( $translation_set ); 114 | $zip_provider->generate_zip_file(); 115 | } 116 | ); 117 | 118 | add_filter( 119 | 'gp_update_meta', 120 | static function ( $meta_tuple ) { 121 | $allowed_keys = [ 122 | Project::VERSION_KEY, // '_traduttore_version'. 123 | Project::TEXT_DOMAIN_KEY, // '_traduttore_text_domain'. 124 | ]; 125 | if ( ! \in_array( $meta_tuple['meta_key'], $allowed_keys, true ) ) { 126 | return $meta_tuple; 127 | } 128 | 129 | $project = ( new ProjectLocator( $meta_tuple['object_id'] ) )->get_project(); 130 | if ( ! $project || ! $project->is_active() ) { 131 | return $meta_tuple; 132 | } 133 | 134 | $current_value = gp_get_meta( $meta_tuple['object_type'], $meta_tuple['object_id'], $meta_tuple['meta_key'] ); 135 | if ( $current_value === $meta_tuple['meta_value'] ) { 136 | return $meta_tuple; 137 | } 138 | 139 | $translation_sets = (array) GP::$translation_set->by_project_id( $project->get_id() ); 140 | /** @var \GP_Translation_Set $translation_set */ 141 | foreach ( $translation_sets as $translation_set ) { 142 | if ( 0 === $translation_set->current_count() ) { 143 | continue; 144 | } 145 | 146 | $zip_provider = new ZipProvider( $translation_set ); 147 | $zip_provider->schedule_generation(); 148 | } 149 | 150 | return $meta_tuple; 151 | } 152 | ); 153 | 154 | add_action( 155 | 'traduttore.update', 156 | function ( $project_id ): void { 157 | $project = ( new ProjectLocator( $project_id ) )->get_project(); 158 | 159 | if ( ! $project ) { 160 | return; 161 | } 162 | 163 | $repository = ( new RepositoryFactory() )->get_repository( $project ); 164 | 165 | if ( ! $repository ) { 166 | return; 167 | } 168 | 169 | $loader = ( new LoaderFactory() )->get_loader( $repository ); 170 | 171 | if ( ! $loader ) { 172 | return; 173 | } 174 | 175 | $updater = new Updater( $project ); 176 | $runner = new Runner( $loader, $updater ); 177 | 178 | $runner->delete_local_repository(); 179 | 180 | $runner->run(); 181 | } 182 | ); 183 | 184 | add_filter( 185 | 'slack_get_events', 186 | static function ( $events ) { 187 | $events['traduttore.zip_generated'] = [ 188 | 'action' => 'traduttore.zip_generated', 189 | 'description' => __( 'When a new translation ZIP file is built', 'traduttore' ), 190 | 'message' => function ( $zip_path, $zip_url, GP_Translation_Set $translation_set ) { 191 | /** @var \GP_Locale $locale */ 192 | $locale = GP_Locales::by_slug( $translation_set->locale ); 193 | 194 | $gp_project = GP::$project->get( $translation_set->project_id ); 195 | 196 | if ( ! $gp_project ) { 197 | return false; 198 | } 199 | 200 | $project = new Project( $gp_project ); 201 | 202 | /** 203 | * Filters whether a Slack notification for translation updates from GitHub should be sent. 204 | * 205 | * @since 3.0.0 206 | * 207 | * @param bool $send_message Whether to send a notification or not. Default true. 208 | * @param \GP_Translation_Set $translation_set Translation set the language pack is for. 209 | * @param \Required\Traduttore\Project $project The project that was updated. 210 | */ 211 | $send_message = apply_filters( 'traduttore.zip_generated_send_notification', true, $translation_set, $project ); 212 | 213 | if ( ! $send_message ) { 214 | return false; 215 | } 216 | 217 | $message = sprintf( 218 | '<%1$s|%2$s>: ZIP file updated for *%3$s*. (<%4$s|Download>)', 219 | home_url( gp_url_project( $project->get_project() ) ), 220 | $project->get_name(), 221 | $locale->english_name, 222 | $zip_url 223 | ); 224 | 225 | /** 226 | * Filters the Slack notification message for when a new language pack has been built. 227 | * 228 | * @since 3.0.0 229 | * 230 | * @param string $message The notification message. 231 | * @param \GP_Translation_Set $translation_set Translation set the language pack is for. 232 | * @param \Required\Traduttore\Project $project The project that was updated. 233 | */ 234 | return apply_filters( 'traduttore.zip_generated_notification_message', $message, $translation_set, $project ); 235 | }, 236 | ]; 237 | 238 | $events['traduttore.updated'] = [ 239 | 'action' => 'traduttore.updated', 240 | 'description' => __( 'When new translations are updated for a project', 'traduttore' ), 241 | 'message' => function ( Project $project, array $stats ) { 242 | [ 243 | $originals_added, 244 | $originals_existing, // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable 245 | $originals_fuzzied, 246 | $originals_obsoleted, 247 | $originals_error, 248 | ] = $stats; 249 | 250 | $send_message = $originals_added + $originals_fuzzied + $originals_obsoleted + $originals_error > 0; 251 | 252 | /** 253 | * Filters whether a Slack notification for translation updates should be sent. 254 | * 255 | * @since 3.0.0 256 | * 257 | * @param bool $send_message Whether to send a notification or not. 258 | * Defaults to true, unless there were 259 | * no string changes at all. 260 | * @param \Required\Traduttore\Project $project The Project that was updated. 261 | * @param array $stats Stats about the number of imported translations. 262 | */ 263 | $send_message = apply_filters( 'traduttore.updated_send_notification', $send_message, $project, $stats ); 264 | 265 | if ( ! $send_message ) { 266 | return false; 267 | } 268 | 269 | $message = sprintf( 270 | '<%1$s|%2$s>: *%3$d* new strings were added, *%4$d* were fuzzied, and *%5$d* were obsoleted. There were *%6$d* errors.', 271 | home_url( gp_url_project( $project->get_project() ) ), 272 | $project->get_name(), 273 | $originals_added, 274 | $originals_fuzzied, 275 | $originals_obsoleted, 276 | $originals_error 277 | ); 278 | 279 | /** 280 | * Filters the Slack notification message when new translations are updated. 281 | * 282 | * @since 3.0.0 283 | * 284 | * @param string $message The notification message. 285 | * @param \Required\Traduttore\Project $project The project that was updated. 286 | * @param array $stats Stats about the number of imported translations. 287 | */ 288 | return apply_filters( 'traduttore.updated_notification_message', $message, $project, $stats ); 289 | }, 290 | ]; 291 | 292 | return $events; 293 | } 294 | ); 295 | 296 | add_filter( 'restricted_site_access_is_restricted', [ $this, 'filter_restricted_site_access_is_restricted' ], 10, 2 ); 297 | } 298 | 299 | /** 300 | * Clears all scheduled hooks upon plugin deactivation. 301 | * 302 | * @since 2.0.0 303 | */ 304 | public static function on_plugin_deactivation(): void { 305 | wp_unschedule_hook( 'traduttore.generate_zip' ); 306 | wp_unschedule_hook( 'traduttore.update' ); 307 | } 308 | 309 | /** 310 | * Sets up translation loading for this plugin using Traduttore Registry. 311 | * 312 | * @since 3.0.0 313 | */ 314 | public function setup_translations(): void { 315 | add_project( 316 | 'plugin', 317 | 'traduttore', 318 | 'https://translate.required.com/api/translations/required/traduttore' 319 | ); 320 | } 321 | 322 | /** 323 | * Registers the translations API route in GlotPress. 324 | * 325 | * @since 3.0.0 326 | */ 327 | public function register_glotpress_api_routes(): void { 328 | GP::$router->add( '/api/translations/(.+?)', [ TranslationApiRoute::class, 'route_callback' ] ); 329 | } 330 | 331 | /** 332 | * Registers new REST API routes. 333 | * 334 | * @since 2.0.0 335 | */ 336 | public function register_rest_routes(): void { 337 | // Legacy GitHub-only route for incoming webhooks. 338 | register_rest_route( 339 | 'github-webhook/v1', 340 | '/push-event', 341 | [ 342 | 'methods' => WP_REST_Server::CREATABLE, 343 | 'callback' => [ $this, 'incoming_webhook_callback' ], 344 | 'permission_callback' => [ $this, 'incoming_webhook_permission_callback' ], 345 | ] 346 | ); 347 | 348 | // General catch-all route for incoming webhooks. 349 | register_rest_route( 350 | 'traduttore/v1', 351 | '/incoming-webhook', 352 | [ 353 | 'methods' => WP_REST_Server::CREATABLE, 354 | 'callback' => [ $this, 'incoming_webhook_callback' ], 355 | 'permission_callback' => [ $this, 'incoming_webhook_permission_callback' ], 356 | ] 357 | ); 358 | } 359 | 360 | /** 361 | * Filter Restricted Site Access to allow external requests to Traduttore's endpoints. 362 | * 363 | * @since 3.0.0 364 | * 365 | * @param bool $is_restricted Whether access is restricted. 366 | * @param \WP $wp The WordPress object. Only available on the front end. 367 | * @return bool Whether access should be restricted. 368 | */ 369 | public function filter_restricted_site_access_is_restricted( bool $is_restricted, \WP $wp ): bool { 370 | if ( $wp instanceof WP && isset( $wp->query_vars['rest_route'] ) ) { 371 | $route = untrailingslashit( $wp->query_vars['rest_route'] ); 372 | 373 | if ( '/github-webhook/v1/push-event' === $route ) { 374 | return false; 375 | } 376 | 377 | if ( '/traduttore/v1/incoming-webhook' === $route ) { 378 | return false; 379 | } 380 | } 381 | 382 | if ( $wp instanceof WP && isset( $wp->query_vars['gp_route'] ) && class_exists( '\GP' ) ) { 383 | $route = untrailingslashit( GP::$router->request_uri() ); 384 | 385 | if ( 0 === strpos( $route, '/api/translations' ) ) { 386 | return false; 387 | } 388 | } 389 | 390 | return $is_restricted; 391 | } 392 | 393 | /** 394 | * Permission callback for incoming webhooks. 395 | * 396 | * Picks a webhook handler based on the request information. 397 | * 398 | * @since 3.0.0 399 | * 400 | * @param \WP_REST_Request $request Request object. 401 | * @return bool True if permission is granted, false otherwise. 402 | * 403 | * @phpstan-param \WP_REST_Request $request 404 | */ 405 | public function incoming_webhook_permission_callback( WP_REST_Request $request ): bool { 406 | $result = false; 407 | $handler = ( new WebhookHandlerFactory() )->get_handler( $request ); 408 | 409 | if ( $handler ) { 410 | $result = $handler->permission_callback(); 411 | } 412 | 413 | /** 414 | * Filters the result of the incoming webhook permission callback. 415 | * 416 | * @since 3.0.0 417 | * 418 | * @param bool $result Permission callback result. True if permission is granted, false otherwise. 419 | * @param \Required\Traduttore\WebhookHandler|null $handler The current webhook handler if found. 420 | * @param \WP_REST_Request $request The current request. 421 | */ 422 | return apply_filters( 'traduttore.incoming_webhook_permission_callback', $result, $handler, $request ); 423 | } 424 | 425 | /** 426 | * Callback for incoming webhooks. 427 | * 428 | * Picks a webhook handler based on the request information. 429 | * 430 | * @since 3.0.0 431 | * 432 | * @param \WP_REST_Request $request Request object. 433 | * @return \WP_Error|\WP_REST_Response REST response on success, error object on failure. 434 | * 435 | * @phpstan-param \WP_REST_Request $request 436 | */ 437 | public function incoming_webhook_callback( WP_REST_Request $request ): \WP_Error|\WP_REST_Response { 438 | $result = new \WP_Error( '400', 'Bad request' ); 439 | $handler = ( new WebhookHandlerFactory() )->get_handler( $request ); 440 | 441 | if ( $handler ) { 442 | $result = $handler->callback(); 443 | } 444 | 445 | /** 446 | * Filters the result of the incoming webhook callback. 447 | * 448 | * @since 3.0.0 449 | * 450 | * @param \WP_Error|\WP_REST_Response $result REST response on success, error object on failure. 451 | * @param \Required\Traduttore\WebhookHandler|null $handler The current webhook handler if found. 452 | * @param \WP_REST_Request $request The current request. 453 | */ 454 | return apply_filters( 'traduttore.incoming_webhook_callback', $result, $handler, $request ); 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /inc/Project.php: -------------------------------------------------------------------------------- 1 | project = $project; 115 | } 116 | 117 | /** 118 | * Returns the actual GlotPress project. 119 | * 120 | * @since 3.0.0 121 | * 122 | * @return \GP_Project GlotPress project. 123 | */ 124 | public function get_project(): GP_Project { 125 | return $this->project; 126 | } 127 | 128 | /** 129 | * Returns the project's ID. 130 | * 131 | * @since 3.0.0 132 | * 133 | * @return int Project ID. 134 | */ 135 | public function get_id(): int { 136 | return (int) $this->project->id; 137 | } 138 | 139 | /** 140 | * Returns the project's name. 141 | * 142 | * @since 3.0.0 143 | * 144 | * @return string Project name. 145 | */ 146 | public function get_name(): string { 147 | return $this->project->name; 148 | } 149 | 150 | /** 151 | * Returns the project's slug. 152 | * 153 | * @since 3.0.0 154 | * 155 | * @return string Project slug. 156 | */ 157 | public function get_slug(): string { 158 | return $this->project->slug; 159 | } 160 | 161 | /** 162 | * Determines whether the project is active. 163 | * 164 | * @since 3.0.0 165 | * 166 | * @return bool Whether the project is active. 167 | */ 168 | public function is_active(): bool { 169 | return 1 === (int) $this->project->active; 170 | } 171 | 172 | /** 173 | * Returns the project's source URL template. 174 | * 175 | * @since 3.0.0 176 | * 177 | * @return string|null Source URL template if set, null otherwise. 178 | */ 179 | public function get_source_url_template(): ?string { 180 | $source_url_template = $this->project->source_url_template(); 181 | 182 | return $source_url_template ?: null; 183 | } 184 | 185 | /** 186 | * Returns the project's repository type (github, gitlab, etc.) 187 | * 188 | * @since 3.0.0 189 | * 190 | * @return string|null Repository type if stored, null otherwise. 191 | */ 192 | public function get_repository_type(): ?string { 193 | /** 194 | * Project type. 195 | * 196 | * @var string|false $type 197 | */ 198 | $type = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_TYPE_KEY ); 199 | 200 | return $type ?: null; 201 | } 202 | 203 | /** 204 | * Updates the project's repository type. 205 | * 206 | * @since 3.0.0 207 | * 208 | * @param string $type The new repository type. 209 | * @return bool Whether the data was successfully saved or not. 210 | */ 211 | public function set_repository_type( string $type ): bool { 212 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_TYPE_KEY, $type, 'project' ); 213 | } 214 | 215 | /** 216 | * Returns the project's repository VSC type (git, hg, svn, etc.) 217 | * 218 | * @since 3.0.0 219 | * 220 | * @return null|string VCS type if stored, null otherwise. 221 | */ 222 | public function get_repository_vcs_type(): ?string { 223 | /** 224 | * VCS type. 225 | * 226 | * @var string|false $type 227 | */ 228 | $type = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_VCS_TYPE_KEY ); 229 | 230 | return $type ?: null; 231 | } 232 | 233 | /** 234 | * Updates the project's repository VCS type. 235 | * 236 | * @since 3.0.0 237 | * 238 | * @param string $type THe new repository VCS type. 239 | * @return bool Whether the data was successfully saved or not. 240 | */ 241 | public function set_repository_vcs_type( string $type ): bool { 242 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_VCS_TYPE_KEY, $type, 'project' ); 243 | } 244 | 245 | /** 246 | * Returns the project's repository URL. 247 | * 248 | * @since 3.0.0 249 | * 250 | * @return null|string Repository URL if stored, null otherwise. 251 | */ 252 | public function get_repository_url(): ?string { 253 | /** 254 | * Repository URL. 255 | * 256 | * @var string|false $url 257 | */ 258 | $url = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_URL_KEY ); 259 | 260 | return $url ?: null; 261 | } 262 | 263 | /** 264 | * Updates the project's repository URL. 265 | * 266 | * @since 3.0.0 267 | * 268 | * @param string $url The new URL. 269 | * @return bool Whether the data was successfully saved or not. 270 | */ 271 | public function set_repository_url( string $url ): bool { 272 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_URL_KEY, $url, 'project' ); 273 | } 274 | 275 | /** 276 | * Returns the project's repository name. 277 | * 278 | * @since 3.0.0 279 | * 280 | * @return null|string Repository name if stored, null otherwise. 281 | */ 282 | public function get_repository_name(): ?string { 283 | /** 284 | * Repository name. 285 | * 286 | * @var string|false $name 287 | */ 288 | $name = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_NAME_KEY ); 289 | 290 | return $name ?: null; 291 | } 292 | 293 | /** 294 | * Updates the project's repository name. 295 | * 296 | * @since 3.0.0 297 | * 298 | * @param string $name The new name. 299 | * @return bool Whether the data was successfully saved or not. 300 | */ 301 | public function set_repository_name( string $name ): bool { 302 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_NAME_KEY, $name, 'project' ); 303 | } 304 | 305 | /** 306 | * Returns the project's repository visibility. 307 | * 308 | * @since 3.0.0 309 | * 310 | * @return null|string Repository visibility if stored, null otherwise. 311 | */ 312 | public function get_repository_visibility(): ?string { 313 | /** 314 | * Repository visibility. 315 | * 316 | * @var string|false $visibility 317 | */ 318 | $visibility = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_VISIBILITY_KEY ); 319 | 320 | return $visibility ?: null; 321 | } 322 | 323 | /** 324 | * Updates the project's repository visibility. 325 | * 326 | * @param string $visibility The new visibility. 327 | * @return bool Whether the data was successfully saved or not. 328 | */ 329 | public function set_repository_visibility( string $visibility ): bool { 330 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_VISIBILITY_KEY, $visibility, 'project' ); 331 | } 332 | 333 | /** 334 | * Returns the project's repository SSH URL. 335 | * 336 | * @since 3.0.0 337 | * 338 | * @return null|string Repository SSH URL if stored, null otherwise. 339 | */ 340 | public function get_repository_ssh_url(): ?string { 341 | /** 342 | * Repository SSH URL. 343 | * 344 | * @var string|false $url 345 | */ 346 | $url = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_SSH_URL_KEY ); 347 | 348 | return $url ?: null; 349 | } 350 | 351 | /** 352 | * Updates the project's repository SSH URL. 353 | * 354 | * @since 3.0.0 355 | * 356 | * @param string $url The new URL. 357 | * @return bool Whether the data was successfully saved or not. 358 | */ 359 | public function set_repository_ssh_url( string $url ): bool { 360 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_SSH_URL_KEY, $url, 'project' ); 361 | } 362 | 363 | /** 364 | * Returns the project's repository HTTPS URL. 365 | * 366 | * @since 3.0.0 367 | * 368 | * @return null|string Repository HTTPS URL if stored, null otherwise. 369 | */ 370 | public function get_repository_https_url(): ?string { 371 | /** 372 | * Repository HTTPS URL. 373 | * 374 | * @var string|false $url 375 | */ 376 | $url = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_HTTPS_URL_KEY ); 377 | 378 | return $url ?: null; 379 | } 380 | 381 | /** 382 | * Updates the project's repository HTTPS URL. 383 | * 384 | * @since 3.0.0 385 | * 386 | * @param string $url The new URL. 387 | * @return bool Whether the data was successfully saved or not. 388 | */ 389 | public function set_repository_https_url( string $url ): bool { 390 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_HTTPS_URL_KEY, $url, 'project' ); 391 | } 392 | 393 | /** 394 | * Returns the project's repository webhook sync secret. 395 | * 396 | * @since 3.0.0 397 | * 398 | * @return null|string Webhook sync secret if stored, null otherwise. 399 | */ 400 | public function get_repository_webhook_secret(): ?string { 401 | /** 402 | * Repository webhook secret. 403 | * 404 | * @var string|false $name 405 | */ 406 | $name = gp_get_meta( 'project', $this->project->id, static::REPOSITORY_WEBHOOK_SECRET_KEY ); 407 | 408 | return $name ?: null; 409 | } 410 | 411 | /** 412 | * Updates the project's repository webhook sync secret. 413 | * 414 | * @since 3.0.0 415 | * 416 | * @param string $secret The new secret. 417 | * @return bool Whether the data was successfully saved or not. 418 | */ 419 | public function set_repository_webhook_secret( string $secret ): bool { 420 | return (bool) gp_update_meta( $this->project->id, static::REPOSITORY_WEBHOOK_SECRET_KEY, $secret, 'project' ); 421 | } 422 | 423 | /** 424 | * Returns the project's text domain. 425 | * 426 | * @since 3.0.0 427 | * 428 | * @return null|string Text domain if stored, null otherwise. 429 | */ 430 | public function get_text_domain(): ?string { 431 | /** 432 | * Project text domain 433 | * 434 | * @var string|false $name 435 | */ 436 | $name = gp_get_meta( 'project', $this->project->id, static::TEXT_DOMAIN_KEY ); 437 | 438 | return $name ?: null; 439 | } 440 | 441 | /** 442 | * Updates the project's text domain. 443 | * 444 | * @since 3.0.0 445 | * 446 | * @param string $name The new text domain. 447 | * @return bool Whether the data was successfully saved or not. 448 | */ 449 | public function set_text_domain( string $name ): bool { 450 | return (bool) gp_update_meta( $this->project->id, static::TEXT_DOMAIN_KEY, $name, 'project' ); 451 | } 452 | 453 | /** 454 | * Returns the time for when the project was last updated. 455 | * 456 | * @since 3.0.0 457 | * 458 | * @return null|\DateTime Last updated time if stored, null otherwise. 459 | */ 460 | public function get_last_updated_time(): ?DateTime { 461 | /** 462 | * Project last updated time. 463 | * 464 | * @var string|false $time 465 | */ 466 | $time = gp_get_meta( 'project', $this->project->id, static::UPDATE_TIME_KEY ); 467 | 468 | return $time ? new DateTime( $time, new DateTimeZone( 'UTC' ) ) : null; 469 | } 470 | 471 | /** 472 | * Updates the time for when the project was last updated. 473 | * 474 | * @since 3.0.0 475 | * 476 | * @param \DateTime $time The new updated time. 477 | * @return bool Whether the data was successfully saved or not. 478 | */ 479 | public function set_last_updated_time( DateTime $time ): bool { 480 | return (bool) gp_update_meta( $this->project->id, static::UPDATE_TIME_KEY, $time->format( DATE_ATOM ), 'project' ); 481 | } 482 | 483 | /** 484 | * Returns the project's version number. 485 | * 486 | * @since 3.0.0 487 | * 488 | * @return null|string Version number if stored, null otherwise. 489 | */ 490 | public function get_version(): ?string { 491 | /** 492 | * Project version number. 493 | * 494 | * @var string|false $version 495 | */ 496 | $version = gp_get_meta( 'project', $this->project->id, static::VERSION_KEY ); 497 | 498 | return $version ?: null; 499 | } 500 | 501 | /** 502 | * Updates the project's version number. 503 | * 504 | * @since 3.0.0 505 | * 506 | * @param string $version The new version number. 507 | * @return bool Whether the data was successfully saved or not. 508 | */ 509 | public function set_version( string $version ): bool { 510 | return (bool) gp_update_meta( $this->project->id, static::VERSION_KEY, $version, 'project' ); 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /inc/ProjectLocator.php: -------------------------------------------------------------------------------- 1 | project = $this->find_project( $project ); 37 | } 38 | 39 | /** 40 | * Returns the found project. 41 | * 42 | * @since 2.0.0 43 | * 44 | * @return \Required\Traduttore\Project GlotPress project. 45 | */ 46 | public function get_project(): ?Project { 47 | return $this->project; 48 | } 49 | 50 | /** 51 | * Attempts to find a GlotPress project. 52 | * 53 | * @since 2.0.0 54 | * 55 | * @param int|string|\Required\Traduttore\Project|\GP_Project $project Possible GlotPress project ID or path or source code repository path. 56 | * @return \Required\Traduttore\Project Project instance. 57 | */ 58 | protected function find_project( int|string|Project|\GP_Project $project ): ?Project { 59 | if ( ! $project ) { 60 | return null; 61 | } 62 | 63 | if ( $project instanceof Project ) { 64 | return $project; 65 | } 66 | 67 | if ( $project instanceof GP_Project ) { 68 | return new Project( $project ); 69 | } 70 | 71 | $found = GP::$project->by_path( $project ); 72 | 73 | if ( ! $found && is_numeric( $project ) ) { 74 | $found = GP::$project->get( (int) $project ); 75 | } 76 | 77 | if ( ! $found ) { 78 | $found = $this->find_by_repository_name( (string) $project ); 79 | } 80 | 81 | if ( ! $found ) { 82 | $found = $this->find_by_repository_url( (string) $project ); 83 | } 84 | 85 | if ( ! $found ) { 86 | $found = $this->find_by_source_url_template( (string) $project ); 87 | } 88 | 89 | return $found ? new Project( $found ) : null; 90 | } 91 | 92 | /** 93 | * Finds a GlotPress project by matching repository name meta data. 94 | * 95 | * @since 3.0.0 96 | * 97 | * @param string $project Possible repository path. 98 | * @return \GP_Project|null Project on success, null otherwise. 99 | */ 100 | protected function find_by_repository_name( string $project ): ?GP_Project { 101 | global $wpdb; 102 | 103 | $meta_key = '_traduttore_repository_name'; 104 | 105 | $query = $wpdb->prepare( 106 | "SELECT object_id FROM `$wpdb->gp_meta` WHERE `object_type` = 'project' AND `meta_key` = %s AND `meta_value` = %s LIMIT 1", 107 | $meta_key, 108 | $project 109 | ); 110 | 111 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 112 | $result = $wpdb->get_row( $query ); 113 | 114 | if ( ! $result ) { 115 | return null; 116 | } 117 | 118 | $gp_project = GP::$project->get( (int) $result->object_id ); 119 | 120 | return $gp_project ?: null; 121 | } 122 | 123 | /** 124 | * Finds a GlotPress project by matching repository URL meta data. 125 | * 126 | * @since 3.0.0 127 | * 128 | * @param string $project Possible repository path or URL. 129 | * @return \GP_Project|null Project on success, null otherwise. 130 | */ 131 | protected function find_by_repository_url( string $project ): ?GP_Project { 132 | global $wpdb; 133 | 134 | $meta_key = '_traduttore_repository_url'; 135 | 136 | $query = $wpdb->prepare( 137 | "SELECT object_id FROM `$wpdb->gp_meta` WHERE `object_type` = 'project' AND `meta_key` = %s AND `meta_value` = %s LIMIT 1", 138 | $meta_key, 139 | $project 140 | ); 141 | 142 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 143 | $result = $wpdb->get_row( $query ); 144 | 145 | if ( ! $result ) { 146 | return null; 147 | } 148 | 149 | $gp_project = GP::$project->get( (int) $result->object_id ); 150 | 151 | return $gp_project ?: null; 152 | } 153 | 154 | /** 155 | * Finds a GlotPress project by a partially matching source_url_template setting. 156 | * 157 | * Given a URL like https://github.com/wearerequired/required-valencia, this would match 158 | * a setting like https://github.com/wearerequired/required-valencia/blob/master/%file%#L%line%. 159 | * 160 | * @since 3.0.0 161 | * 162 | * @param string $project Possible source code repository path or URL. 163 | * @return \GP_Project|null Project on success, null otherwise. 164 | */ 165 | protected function find_by_source_url_template( string $project ): ?GP_Project { 166 | global $wpdb; 167 | 168 | // Make sure a URL like '…/required' doesn't match …/required-valencia/blob/…'. 169 | $project = trailingslashit( $project ); 170 | 171 | $query = $wpdb->prepare( 172 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 173 | "SELECT * FROM `$wpdb->gp_projects` WHERE source_url_template LIKE %s LIMIT 1", 174 | $wpdb->esc_like( $project ) . '%' 175 | ); 176 | 177 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 178 | $gp_project = GP::$project->coerce( $wpdb->get_row( $query ) ); 179 | 180 | /** @var \GP_Project|false $gp_project */ 181 | return $gp_project ?: null; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /inc/Repository.php: -------------------------------------------------------------------------------- 1 | project = $project; 37 | } 38 | 39 | /** 40 | * Returns the repository type. 41 | * 42 | * @since 3.0.0 43 | * 44 | * @return string Repository type. 45 | */ 46 | public function get_type(): string { 47 | $type = $this->project->get_repository_type(); 48 | 49 | return $type ?: Repository::TYPE_UNKNOWN; 50 | } 51 | 52 | /** 53 | * Indicates whether a repository is publicly accessible or not. 54 | * 55 | * @since 3.0.0 56 | * 57 | * @return bool Whether the repository is publicly accessible. 58 | */ 59 | public function is_public(): bool { 60 | return 'public' === $this->project->get_repository_visibility(); 61 | } 62 | 63 | /** 64 | * Returns the project. 65 | * 66 | * @since 3.0.0 67 | * 68 | * @return \Required\Traduttore\Project The project. 69 | */ 70 | public function get_project(): Project { 71 | return $this->project; 72 | } 73 | 74 | /** 75 | * Returns the repository host name. 76 | * 77 | * @since 3.0.0 78 | * 79 | * @return string Repository host name. 80 | */ 81 | public function get_host(): ?string { 82 | $url = $this->project->get_repository_url(); 83 | $host = $url ? wp_parse_url( $url, PHP_URL_HOST ) : null; 84 | 85 | return $host ?: null; 86 | } 87 | 88 | /** 89 | * Returns the repository name. 90 | * 91 | * If the name is not stored in the database, it tries to determine it from the repository URL 92 | * and ultimately the project slug. 93 | * 94 | * @since 3.0.0 95 | * 96 | * @return string Repository name. 97 | */ 98 | public function get_name(): string { 99 | $name = $this->project->get_repository_name(); 100 | 101 | if ( ! $name ) { 102 | $url = $this->project->get_repository_url(); 103 | 104 | if ( ! $url ) { 105 | $url = $this->project->get_source_url_template(); 106 | } 107 | 108 | if ( $url ) { 109 | $path = wp_parse_url( $url, PHP_URL_PATH ); 110 | $path = $path ? trim( $path, '/' ) : ''; 111 | $parts = explode( '/', $path ); 112 | $name = implode( '/', array_splice( $parts, 0, 2 ) ); 113 | } 114 | } 115 | 116 | return $name ?: $this->project->get_project()->slug; 117 | } 118 | 119 | /** 120 | * Returns the repository's SSH URL for cloning based on the project's source URL template. 121 | * 122 | * @since 3.0.0 123 | * 124 | * @return string SSH URL to the repository, e.g. git@github.com:wearerequired/traduttore.git. 125 | */ 126 | public function get_ssh_url(): ?string { 127 | $ssh_url = $this->project->get_repository_ssh_url(); 128 | 129 | if ( $ssh_url ) { 130 | return $ssh_url; 131 | } 132 | 133 | if ( $this->get_host() && $this->get_name() ) { 134 | return sprintf( 'git@%1$s:%2$s.git', $this->get_host(), $this->get_name() ); 135 | } 136 | 137 | return null; 138 | } 139 | 140 | /** 141 | * Returns the repository's HTTPS URL for cloning based on the project's source URL template. 142 | * 143 | * @since 3.0.0 144 | * 145 | * @return string HTTPS URL to the repository, e.g. https://github.com/wearerequired/traduttore.git. 146 | */ 147 | public function get_https_url(): ?string { 148 | $https_url = $this->project->get_repository_https_url(); 149 | 150 | if ( ! $https_url && $this->get_host() && $this->get_name() ) { 151 | $https_url = sprintf( 'https://%1$s/%2$s.git', $this->get_host(), $this->get_name() ); 152 | } 153 | 154 | if ( ! $https_url ) { 155 | return null; 156 | } 157 | 158 | /** 159 | * Filters the credentials to be used for connecting to a Git repository via HTTPS. 160 | * 161 | * @since 3.0.0 162 | * 163 | * @param string $credentials HTTP authentication credentials in the form 164 | * username:password. Default empty string. 165 | * @param \Required\Traduttore\Repository $repository The current repository. 166 | */ 167 | $credentials = apply_filters( 'traduttore.git_https_credentials', '', $this ); 168 | 169 | if ( ! empty( $credentials ) ) { 170 | $https_url = str_replace( 'https://', 'https://' . $credentials . '@', $https_url ); 171 | } 172 | 173 | return $https_url; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /inc/Repository/Bitbucket.php: -------------------------------------------------------------------------------- 1 | project->get_repository_visibility(); 56 | 57 | if ( ! $visibility ) { 58 | $response = wp_remote_head( self::API_BASE . '/repositories/' . $this->get_name() ); 59 | 60 | $visibility = 200 === wp_remote_retrieve_response_code( $response ) ? 'public' : 'private'; 61 | 62 | $this->project->set_repository_visibility( $visibility ); 63 | } 64 | 65 | return parent::is_public(); 66 | } 67 | 68 | /** 69 | * Returns the repository's SSH URL for cloning based on the project's source URL template. 70 | * 71 | * @since 3.0.0 72 | * 73 | * @return string SSH URL to the repository, e.g. git@github.com:wearerequired/traduttore.git. 74 | */ 75 | public function get_ssh_url(): ?string { 76 | if ( Repository::VCS_TYPE_HG === $this->project->get_repository_vcs_type() ) { 77 | $ssh_url = $this->project->get_repository_ssh_url(); 78 | 79 | if ( $ssh_url ) { 80 | return $ssh_url; 81 | } 82 | 83 | if ( $this->get_host() && $this->get_name() ) { 84 | return sprintf( 'hg@%1$s/%2$s', $this->get_host(), $this->get_name() ); 85 | } 86 | } 87 | 88 | return parent::get_ssh_url(); 89 | } 90 | 91 | /** 92 | * Returns the repository's HTTPS URL for cloning based on the project's source URL template. 93 | * 94 | * @since 3.0.0 95 | * 96 | * @return string HTTPS URL to the repository, e.g. https://github.com/wearerequired/traduttore.git. 97 | */ 98 | public function get_https_url(): ?string { 99 | if ( Repository::VCS_TYPE_HG === $this->project->get_repository_vcs_type() ) { 100 | $https_url = $this->project->get_repository_https_url(); 101 | 102 | if ( ! $https_url && $this->get_host() && $this->get_name() ) { 103 | $https_url = sprintf( 'https://%1$s/%2$s', $this->get_host(), $this->get_name() ); 104 | } 105 | 106 | if ( ! $https_url ) { 107 | return null; 108 | } 109 | 110 | /** 111 | * Filters the credentials to be used for connecting to a Mercurial repository via HTTPS. 112 | * 113 | * @since 3.0.0 114 | * 115 | * @param string $credentials HTTP authentication credentials in the form 116 | * username:password. Default empty string. 117 | * @param \Required\Traduttore\Repository $repository The current repository. 118 | */ 119 | $credentials = apply_filters( 'traduttore.hg_https_credentials', '', $this ); 120 | 121 | if ( ! empty( $credentials ) ) { 122 | $https_url = str_replace( 'https://', 'https://' . $credentials . '@', $https_url ); 123 | } 124 | 125 | return $https_url; 126 | } 127 | 128 | return parent::get_https_url(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /inc/Repository/GitHub.php: -------------------------------------------------------------------------------- 1 | project->get_repository_visibility(); 56 | 57 | if ( ! $visibility ) { 58 | $response = wp_remote_head( self::API_BASE . '/repos/' . $this->get_name() ); 59 | 60 | $visibility = 200 === wp_remote_retrieve_response_code( $response ) ? 'public' : 'private'; 61 | 62 | $this->project->set_repository_visibility( $visibility ); 63 | } 64 | 65 | return parent::is_public(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /inc/Repository/GitLab.php: -------------------------------------------------------------------------------- 1 | project->get_repository_visibility(); 62 | 63 | if ( ! $visibility ) { 64 | $response = wp_remote_head( self::API_BASE . '/projects/' . rawurlencode( $this->get_name() ) ); 65 | 66 | $visibility = 200 === wp_remote_retrieve_response_code( $response ) ? 'public' : 'private'; 67 | 68 | $this->project->set_repository_visibility( $visibility ); 69 | } 70 | 71 | return parent::is_public(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /inc/RepositoryFactory.php: -------------------------------------------------------------------------------- 1 | get_repository_type(); 32 | 33 | switch ( $repository_type ) { 34 | case Repository::TYPE_BITBUCKET: 35 | $repository = new Bitbucket( $project ); 36 | break; 37 | case Repository::TYPE_GITHUB: 38 | $repository = new GitHub( $project ); 39 | break; 40 | case Repository::TYPE_GITLAB: 41 | $repository = new GitLab( $project ); 42 | break; 43 | } 44 | 45 | if ( ! $repository && ! $repository_type ) { 46 | $url = $project->get_repository_url(); 47 | 48 | if ( ! $url ) { 49 | $url = $project->get_source_url_template(); 50 | } 51 | 52 | $host = $url ? wp_parse_url( $url, PHP_URL_HOST ) : null; 53 | 54 | switch ( $host ) { 55 | case 'github.com': 56 | $repository = new GitHub( $project ); 57 | break; 58 | case 'gitlab.com': 59 | $repository = new GitLab( $project ); 60 | break; 61 | case 'bitbucket.org': 62 | $repository = new Bitbucket( $project ); 63 | break; 64 | } 65 | } 66 | 67 | /** 68 | * Filters the determined repository instance for a given project. 69 | * 70 | * Can be used to set a custom handler for self-managed repositories. 71 | * 72 | * @param \Required\Traduttore\Repository|null $repository Repository instance. 73 | * @param \Required\Traduttore\Project $project Project information. 74 | */ 75 | return apply_filters( 'traduttore.repository', $repository, $project ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /inc/Runner.php: -------------------------------------------------------------------------------- 1 | loader = $loader; 46 | $this->updater = $updater; 47 | } 48 | 49 | /** 50 | * Attempts to delete the folder containing the local repository checkout. 51 | * 52 | * @since 3.0.0 53 | * 54 | * @return bool True on success, false on failure. 55 | */ 56 | public function delete_local_repository(): bool { 57 | /** @var \WP_Filesystem_Base|null $wp_filesystem */ 58 | global $wp_filesystem; 59 | 60 | if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { 61 | require_once ABSPATH . '/wp-admin/includes/admin.php'; 62 | 63 | if ( ! \WP_Filesystem() ) { 64 | return false; 65 | } 66 | } 67 | 68 | if ( ! $wp_filesystem ) { 69 | return false; 70 | } 71 | 72 | return $wp_filesystem->rmdir( $this->loader->get_local_path(), true ); 73 | } 74 | 75 | /** 76 | * Updates the project's translations based on the source code. 77 | * 78 | * @since 3.0.0 79 | * 80 | * @param bool $cached Whether to use cached source code instead of updated one. 81 | * @return bool True on success, false otherwise. 82 | */ 83 | public function run( bool $cached = false ): bool { 84 | if ( $this->updater->has_lock() ) { 85 | return false; 86 | } 87 | 88 | $this->updater->add_lock(); 89 | 90 | $local_repository = $cached ? $this->loader->get_local_path() : $this->loader->download(); 91 | 92 | if ( ! $local_repository || ! is_dir( $local_repository ) ) { 93 | $this->updater->remove_lock(); 94 | 95 | return false; 96 | } 97 | 98 | $configuration = new Configuration( $local_repository ); 99 | 100 | $result = $this->updater->update( $configuration ); 101 | 102 | $this->updater->remove_lock(); 103 | 104 | return $result; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /inc/TranslationApiRoute.php: -------------------------------------------------------------------------------- 1 | header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); 29 | 30 | // Get the project object from the project path that was passed in. 31 | $gp_project = GP::$project->by_path( $project_path ); 32 | 33 | if ( ! $gp_project ) { 34 | $this->status_header( 404 ); 35 | echo wp_json_encode( [ 'error' => 'Project not found.' ] ); 36 | return; 37 | } 38 | 39 | $project = new Project( $gp_project ); 40 | 41 | $translation_sets = (array) GP::$translation_set->by_project_id( $project->get_id() ); 42 | 43 | $result = []; 44 | 45 | /** @var \GP_Translation_Set $set */ 46 | foreach ( $translation_sets as $set ) { 47 | /** @var \GP_Locale $locale */ 48 | $locale = GP_Locales::by_slug( $set->locale ); 49 | 50 | $zip_provider = new ZipProvider( $set ); 51 | 52 | if ( ! $zip_provider->get_last_build_time() || ! file_exists( $zip_provider->get_zip_path() ) ) { 53 | continue; 54 | } 55 | 56 | $result[] = [ 57 | 'language' => $locale->wp_locale, 58 | 'version' => $project->get_version() ?? '1.0', 59 | 'updated' => $zip_provider->get_last_build_time()->format( DATE_ATOM ), 60 | 'english_name' => $locale->english_name, 61 | 'native_name' => $locale->native_name, 62 | 'package' => $zip_provider->get_zip_url(), 63 | 'iso' => array_filter( 64 | [ 65 | $locale->lang_code_iso_639_1, 66 | $locale->lang_code_iso_639_2, 67 | $locale->lang_code_iso_639_3, 68 | ] 69 | ), 70 | ]; 71 | } 72 | 73 | echo wp_json_encode( [ 'translations' => $result ] ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /inc/Updater.php: -------------------------------------------------------------------------------- 1 | project = $project; 46 | } 47 | 48 | /** 49 | * Schedules an update for the current project. 50 | * 51 | * Adds a single cron event to update the project after a short amount of time. 52 | * 53 | * @since 3.0.0 54 | */ 55 | public function schedule_update(): void { 56 | /** 57 | * Filters the delay for scheduled project updates. 58 | * 59 | * @since 3.0.0 60 | * 61 | * @param int $delay Delay in minutes. Default is 3 minutes. 62 | * @param \Required\Traduttore\Project $project The current project. 63 | */ 64 | $delay = (int) apply_filters( 'traduttore.update_delay', MINUTE_IN_SECONDS * 3, $this->project ); 65 | 66 | $next_schedule = wp_next_scheduled( 'traduttore.update', [ $this->project->get_id() ] ); 67 | 68 | if ( $next_schedule ) { 69 | wp_unschedule_event( $next_schedule, 'traduttore.update', [ $this->project->get_id() ] ); 70 | } 71 | 72 | wp_schedule_single_event( time() + $delay, 'traduttore.update', [ $this->project->get_id() ] ); 73 | } 74 | 75 | /** 76 | * Adds a lock to the current project to prevent two simultaneous imports. 77 | * 78 | * @since 3.0.0 79 | */ 80 | public function add_lock(): void { 81 | gp_update_meta( $this->project->get_id(), static::LOCK_KEY, 1, 'project' ); 82 | } 83 | 84 | /** 85 | * Checks whether an import is currently in progress or not. 86 | * 87 | * @since 3.0.0 88 | * 89 | * @return bool Whether the project is locked. 90 | */ 91 | public function has_lock(): bool { 92 | return (bool) gp_get_meta( 'project', $this->project->get_id(), static::LOCK_KEY ); 93 | } 94 | 95 | /** 96 | * Removes the lock for the current project. 97 | * 98 | * @since 3.0.0 99 | */ 100 | public function remove_lock(): void { 101 | gp_delete_meta( $this->project->get_id(), static::LOCK_KEY, null, 'project' ); 102 | } 103 | 104 | /** 105 | * Updates the project based on the given configuration. 106 | * 107 | * @since 3.0.0 108 | * 109 | * @param \Required\Traduttore\Configuration $config Configuration object. 110 | * @return bool True on success, false otherwise. 111 | */ 112 | public function update( Configuration $config ): bool { 113 | $pot_file = $this->create_pot_file( $config ); 114 | 115 | if ( ! $pot_file ) { 116 | return false; 117 | } 118 | 119 | $translations = new PO(); 120 | $result = $translations->import_from_file( $pot_file ); 121 | 122 | unlink( $pot_file ); 123 | 124 | if ( ! $result ) { 125 | return false; 126 | } 127 | 128 | $this->project->set_text_domain( sanitize_text_field( $translations->headers['X-Domain'] ) ); 129 | 130 | if ( $translations->headers['Project-Id-Version'] ) { 131 | $this->project->set_version( $this->extract_version( $translations->headers['Project-Id-Version'] ) ?? '' ); 132 | } 133 | 134 | $stats = GP::$original->import_for_project( $this->project->get_project(), $translations ); 135 | 136 | $this->project->set_last_updated_time( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) ); 137 | 138 | /** 139 | * Fires after translations have been updated. 140 | * 141 | * @since 3.0.0 142 | * 143 | * @param \Required\Traduttore\Project $project The project that was updated. 144 | * @param array $stats Stats about the number of imported translations. 145 | * @param \PO $translations PO object containing all the translations from the POT file. 146 | */ 147 | do_action( 'traduttore.updated', $this->project, $stats, $translations ); 148 | 149 | return true; 150 | } 151 | 152 | /** 153 | * Extracts the version number from the Project-Id-Version POT header. 154 | * 155 | * @since 3.0.0 156 | * 157 | * @param string $project_id_version Project-Id-Version header string. 158 | * @return null|string Version number on success, null otherwise. 159 | */ 160 | protected function extract_version( string $project_id_version ): ?string { 161 | if ( false === strpos( $project_id_version, ' ' ) ) { 162 | return null; 163 | } 164 | 165 | $parts = explode( ' ', $project_id_version ); 166 | 167 | return array_pop( $parts ); 168 | } 169 | 170 | /** 171 | * Creates a POT file from a given source directory. 172 | * 173 | * @since 3.0.0 174 | * 175 | * @param \Required\Traduttore\Configuration $config Configuration object. 176 | * @return string Path to the POT file. 177 | */ 178 | protected function create_pot_file( Configuration $config ): ?string { 179 | $source = $config->get_path(); 180 | $merge = $config->get_config_value( 'mergeWith' ); 181 | $domain = $config->get_config_value( 'textDomain' ); 182 | $exclude = $config->get_config_value( 'exclude' ); 183 | 184 | $merge = $merge ? $source . '/' . ltrim( $merge, '/' ) : null; 185 | 186 | if ( $merge && ! file_exists( $merge ) ) { 187 | $merge = null; 188 | } 189 | 190 | $target = $this->get_temp_pot_file(); 191 | 192 | exec( 193 | escapeshellcmd( 194 | trim( 195 | sprintf( 196 | '%1$s i18n make-pot %2$s %3$s --slug=%4$s %5$s %6$s %7$s', 197 | $this->get_wp_bin(), 198 | escapeshellarg( $source ), 199 | escapeshellarg( $target ), 200 | escapeshellarg( $this->project->get_slug() ), 201 | $merge ? escapeshellarg( '--merge=' . $merge ) : '', 202 | $domain ? escapeshellarg( '--domain=' . $domain ) : '', 203 | $exclude ? escapeshellarg( '--exclude=' . implode( ',', $exclude ) ) : '' 204 | ) 205 | ) 206 | ), 207 | $output, 208 | $status 209 | ); 210 | 211 | return 0 === $status ? $target : null; 212 | } 213 | 214 | /** 215 | * Returns the path to the WP-CLI binary. 216 | * 217 | * Allows overriding the path to the binary via the TRADUTTORE_WP_BIN constant. 218 | * 219 | * @since 3.0.0 220 | * 221 | * @return string WP-CLI binary path. 222 | */ 223 | protected function get_wp_bin(): string { 224 | if ( \defined( 'TRADUTTORE_WP_BIN' ) && \is_string( TRADUTTORE_WP_BIN ) ) { 225 | return TRADUTTORE_WP_BIN; 226 | } 227 | 228 | return 'wp'; 229 | } 230 | 231 | /** 232 | * Returns the path to the temporary POT file. 233 | * 234 | * @since 3.0.0 235 | * 236 | * @return string POT file path. 237 | */ 238 | protected function get_temp_pot_file(): string { 239 | require_once ABSPATH . 'wp-admin/includes/file.php'; 240 | 241 | return wp_tempnam( sprintf( 'traduttore-%s.pot', $this->project->get_slug() ) ); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /inc/WebhookHandler.php: -------------------------------------------------------------------------------- 1 | $request 26 | */ 27 | public function __construct( WP_REST_Request $request ); 28 | 29 | /** 30 | * Permission callback for incoming webhooks. 31 | * 32 | * @since 3.0.0 33 | * 34 | * @return bool True if permission is granted, false otherwise. 35 | */ 36 | public function permission_callback(): bool; 37 | 38 | /** 39 | * Callback for incoming webhooks. 40 | * 41 | * @since 3.0.0 42 | * 43 | * @return \WP_Error|\WP_REST_Response REST response on success, error object on failure. 44 | */ 45 | public function callback(): \WP_Error|\WP_REST_Response; 46 | } 47 | -------------------------------------------------------------------------------- /inc/WebhookHandler/Base.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | protected WP_REST_Request $request; 30 | 31 | /** 32 | * Class constructor. 33 | * 34 | * @since 3.0.0 35 | * 36 | * @param \WP_REST_Request $request Request object. 37 | * 38 | * @phpstan-param \WP_REST_Request $request 39 | */ 40 | public function __construct( WP_REST_Request $request ) { 41 | $this->request = $request; 42 | } 43 | 44 | /** 45 | * Returns the webhook sync secret. 46 | * 47 | * @since 3.0.0 48 | * 49 | * @param \Required\Traduttore\Project|null $project The current project if found. 50 | * @return string Secret if set, null otherwise. 51 | */ 52 | protected function get_secret( ?Project $project = null ): ?string { 53 | $secret = null; 54 | 55 | switch ( static::class ) { 56 | case Bitbucket::class: 57 | if ( \defined( 'TRADUTTORE_BITBUCKET_SYNC_SECRET' ) ) { 58 | $secret = TRADUTTORE_BITBUCKET_SYNC_SECRET; 59 | } 60 | break; 61 | case GitHub::class: 62 | if ( \defined( 'TRADUTTORE_GITHUB_SYNC_SECRET' ) ) { 63 | $secret = TRADUTTORE_GITHUB_SYNC_SECRET; 64 | } 65 | break; 66 | case GitLab::class: 67 | if ( \defined( 'TRADUTTORE_GITLAB_SYNC_SECRET' ) ) { 68 | $secret = TRADUTTORE_GITLAB_SYNC_SECRET; 69 | } 70 | break; 71 | } 72 | 73 | $project_secret = $project ? $project->get_repository_webhook_secret() : null; 74 | 75 | $secret = $project_secret ?? $secret; 76 | 77 | /** 78 | * Filters the sync secret for an incoming webhook request. 79 | * 80 | * @since 3.0.0 81 | * 82 | * @param string|null $secret Webhook sync secret. 83 | * @param \Required\Traduttore\WebhookHandler $handler The current webhook handler instance. 84 | * @param \Required\Traduttore\Project|null $project The current project if passed through. 85 | */ 86 | return apply_filters( 'traduttore.webhook_secret', $secret, $this, $project ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /inc/WebhookHandler/Bitbucket.php: -------------------------------------------------------------------------------- 1 | request->get_header( 'x-event-key' ); 32 | 33 | if ( 'repo:push' !== $event_name ) { 34 | return false; 35 | } 36 | 37 | $token = $this->request->get_header( 'x-hub-signature-256' ); 38 | $params = $this->request->get_params(); 39 | $repository = $params['repository']['links']['html']['href'] ?? null; 40 | 41 | if ( ! $repository ) { 42 | return false; 43 | } 44 | 45 | $locator = new ProjectLocator( $repository ); 46 | $project = $locator->get_project(); 47 | $secret = $this->get_secret( $project ); 48 | 49 | if ( $token ) { 50 | if ( ! $secret ) { 51 | return false; 52 | } 53 | 54 | $payload_signature = 'sha256=' . hash_hmac( 'sha256', $this->request->get_body(), $secret ); 55 | 56 | return hash_equals( $token, $payload_signature ); 57 | } 58 | 59 | return true; 60 | } 61 | 62 | /** 63 | * Callback for incoming Bitbucket webhooks. 64 | * 65 | * @since 3.0.0 66 | * 67 | * @return \WP_Error|\WP_REST_Response REST response on success, error object on failure. 68 | */ 69 | public function callback(): \WP_Error|\WP_REST_Response { 70 | $params = $this->request->get_params(); 71 | 72 | $locator = new ProjectLocator( $params['repository']['links']['html']['href'] ); 73 | $project = $locator->get_project(); 74 | 75 | if ( ! $project ) { 76 | return new \WP_Error( '404', 'Could not find project for this repository' ); 77 | } 78 | 79 | if ( ! $project->get_repository_vcs_type() ) { 80 | $project->set_repository_vcs_type( 'git' === $params['repository']['scm'] ? Repository::VCS_TYPE_GIT : Repository::VCS_TYPE_HG ); 81 | } 82 | 83 | $project->set_repository_name( $params['repository']['full_name'] ); 84 | $project->set_repository_url( $params['repository']['links']['html']['href'] ); 85 | 86 | $ssh_url = sprintf( 'git@bitbucket.org:%s.git', $project->get_repository_name() ); 87 | $https_url = sprintf( 'https://bitbucket.org/%s.git', $project->get_repository_name() ); 88 | 89 | if ( Repository::VCS_TYPE_HG === $project->get_repository_vcs_type() ) { 90 | $ssh_url = sprintf( 'hg@bitbucket.org/%s', $project->get_repository_name() ); 91 | $https_url = sprintf( 'https://bitbucket.org/%s', $project->get_repository_name() ); 92 | } 93 | 94 | $project->set_repository_ssh_url( $ssh_url ); 95 | $project->set_repository_https_url( $https_url ); 96 | 97 | $project->set_repository_visibility( false === $params['repository']['is_private'] ? 'public' : 'private' ); 98 | 99 | if ( ! $project->get_repository_type() ) { 100 | $project->set_repository_type( Repository::TYPE_BITBUCKET ); 101 | } 102 | 103 | ( new Updater( $project ) )->schedule_update(); 104 | 105 | return new WP_REST_Response( [ 'result' => 'OK' ] ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /inc/WebhookHandler/GitHub.php: -------------------------------------------------------------------------------- 1 | request->get_header( 'x-github-event' ); 32 | 33 | if ( 'ping' === $event_name ) { 34 | return true; 35 | } 36 | 37 | if ( 'push' !== $event_name ) { 38 | return false; 39 | } 40 | 41 | $token = $this->request->get_header( 'x-hub-signature-256' ); 42 | 43 | if ( ! $token ) { 44 | return false; 45 | } 46 | 47 | $params = $this->request->get_params(); 48 | $content_type = $this->request->get_content_type(); 49 | 50 | // See https://developer.github.com/webhooks/creating/#content-type. 51 | if ( ! empty( $content_type ) && 'application/x-www-form-urlencoded' === $content_type['value'] ) { 52 | $params = json_decode( $params['payload'], true ); 53 | } 54 | 55 | /** 56 | * Request params. 57 | * 58 | * @var array{repository: array{ default_branch?: string, html_url?: string, full_name: string, ssh_url: string, clone_url: string, private: bool }, ref: string } $params 59 | */ 60 | 61 | $repository = $params['repository']['html_url'] ?? null; 62 | 63 | if ( ! $repository ) { 64 | return false; 65 | } 66 | 67 | $locator = new ProjectLocator( $repository ); 68 | $project = $locator->get_project(); 69 | 70 | $secret = $this->get_secret( $project ); 71 | 72 | if ( ! $secret ) { 73 | return false; 74 | } 75 | 76 | $payload_signature = 'sha256=' . hash_hmac( 'sha256', $this->request->get_body(), $secret ); 77 | 78 | return hash_equals( $token, $payload_signature ); 79 | } 80 | 81 | /** 82 | * Callback for incoming GitHub webhooks. 83 | * 84 | * @since 3.0.0 85 | * 86 | * @return \WP_Error|\WP_REST_Response REST response on success, error object on failure. 87 | */ 88 | public function callback(): \WP_Error|\WP_REST_Response { 89 | $event_name = $this->request->get_header( 'x-github-event' ); 90 | 91 | if ( 'ping' === $event_name ) { 92 | return new WP_REST_Response( [ 'result' => 'OK' ] ); 93 | } 94 | 95 | $params = $this->request->get_params(); 96 | $content_type = $this->request->get_content_type(); 97 | 98 | // See https://developer.github.com/webhooks/creating/#content-type. 99 | if ( ! empty( $content_type ) && 'application/x-www-form-urlencoded' === $content_type['value'] ) { 100 | $params = json_decode( $params['payload'], true ); 101 | } 102 | 103 | /** 104 | * Request params. 105 | * 106 | * @var array{repository: array{ default_branch?: string, html_url: string, full_name: string, ssh_url: string, clone_url: string, private: bool }, ref: string } $params 107 | */ 108 | 109 | if ( ! isset( $params['repository']['default_branch'] ) ) { 110 | return new \WP_Error( '400', 'Request incomplete', [ 'status' => 400 ] ); 111 | } 112 | 113 | // We only care about the default branch but don't want to send an error still. 114 | if ( 'refs/heads/' . $params['repository']['default_branch'] !== $params['ref'] ) { 115 | return new WP_REST_Response( [ 'result' => 'Not the default branch' ] ); 116 | } 117 | 118 | $locator = new ProjectLocator( $params['repository']['html_url'] ); 119 | $project = $locator->get_project(); 120 | 121 | if ( ! $project ) { 122 | return new \WP_Error( '404', 'Could not find project for this repository', [ 'status' => 404 ] ); 123 | } 124 | 125 | $project->set_repository_name( $params['repository']['full_name'] ); 126 | $project->set_repository_url( $params['repository']['html_url'] ); 127 | $project->set_repository_ssh_url( $params['repository']['ssh_url'] ); 128 | $project->set_repository_https_url( $params['repository']['clone_url'] ); 129 | $project->set_repository_visibility( false === $params['repository']['private'] ? 'public' : 'private' ); 130 | 131 | if ( ! $project->get_repository_type() ) { 132 | $project->set_repository_type( Repository::TYPE_GITHUB ); 133 | } 134 | 135 | if ( ! $project->get_repository_vcs_type() ) { 136 | $project->set_repository_vcs_type( Repository::VCS_TYPE_GIT ); 137 | } 138 | 139 | ( new Updater( $project ) )->schedule_update(); 140 | 141 | return new WP_REST_Response( [ 'result' => 'OK' ] ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /inc/WebhookHandler/GitLab.php: -------------------------------------------------------------------------------- 1 | request->get_header( 'x-gitlab-event' ); 32 | 33 | if ( 'Push Hook' !== $event_name ) { 34 | return false; 35 | } 36 | 37 | $token = $this->request->get_header( 'x-gitlab-token' ); 38 | 39 | if ( ! $token ) { 40 | return false; 41 | } 42 | 43 | $params = $this->request->get_params(); 44 | $repository = $params['project']['homepage'] ?? null; 45 | 46 | if ( ! $repository ) { 47 | return false; 48 | } 49 | 50 | $locator = new ProjectLocator( $repository ); 51 | $project = $locator->get_project(); 52 | 53 | $secret = $this->get_secret( $project ); 54 | 55 | if ( ! $secret ) { 56 | return false; 57 | } 58 | 59 | return hash_equals( $token, $secret ); 60 | } 61 | 62 | /** 63 | * Callback for incoming GitLab webhooks. 64 | * 65 | * @since 3.0.0 66 | * 67 | * @return \WP_Error|\WP_REST_Response REST response on success, error object on failure. 68 | */ 69 | public function callback(): \WP_Error|\WP_REST_Response { 70 | $params = $this->request->get_params(); 71 | 72 | // We only care about the default branch but don't want to send an error still. 73 | if ( 'refs/heads/' . $params['project']['default_branch'] !== $params['ref'] ) { 74 | return new WP_REST_Response( [ 'result' => 'Not the default branch' ] ); 75 | } 76 | 77 | $locator = new ProjectLocator( $params['project']['homepage'] ); 78 | $project = $locator->get_project(); 79 | 80 | if ( ! $project ) { 81 | return new \WP_Error( '404', 'Could not find project for this repository' ); 82 | } 83 | 84 | $project->set_repository_name( $params['project']['path_with_namespace'] ); 85 | $project->set_repository_url( $params['project']['homepage'] ); 86 | $project->set_repository_ssh_url( $params['project']['ssh_url'] ); 87 | $project->set_repository_https_url( $params['project']['http_url'] ); 88 | $project->set_repository_visibility( 20 === $params['project']['visibility_level'] ? 'public' : 'private' ); 89 | 90 | if ( ! $project->get_repository_type() ) { 91 | $project->set_repository_type( Repository::TYPE_GITLAB ); 92 | } 93 | 94 | if ( ! $project->get_repository_vcs_type() ) { 95 | $project->set_repository_vcs_type( Repository::VCS_TYPE_GIT ); 96 | } 97 | 98 | ( new Updater( $project ) )->schedule_update(); 99 | 100 | return new WP_REST_Response( [ 'result' => 'OK' ] ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /inc/WebhookHandlerFactory.php: -------------------------------------------------------------------------------- 1 | $request 30 | */ 31 | public function get_handler( WP_REST_Request $request ): ?WebhookHandler { 32 | $handler = null; 33 | 34 | if ( $request->get_header( 'x-github-event' ) ) { 35 | $handler = new GitHub( $request ); 36 | } elseif ( $request->get_header( 'x-gitlab-event' ) ) { 37 | $handler = new GitLab( $request ); 38 | } elseif ( $request->get_header( 'x-event-key' ) ) { 39 | $handler = new Bitbucket( $request ); 40 | } 41 | 42 | /** 43 | * Filters the determined incoming webhook handler. 44 | * 45 | * @param \Required\Traduttore\WebhookHandler|null $handler Webhook handler instance. 46 | * @param \WP_REST_Request $request The current request object. 47 | */ 48 | return apply_filters( 'traduttore.webhook_handler', $handler, $request ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /inc/ZipProvider.php: -------------------------------------------------------------------------------- 1 | translation_set = $translation_set; 74 | $this->locale = GP_Locales::by_slug( $this->translation_set->locale ); 75 | 76 | /** 77 | * GlotPress project. 78 | * 79 | * @var \GP_Project $gp_project 80 | */ 81 | $gp_project = GP::$project->get( $this->translation_set->project_id ); 82 | 83 | $this->project = new Project( $gp_project ); 84 | } 85 | 86 | /** 87 | * Schedules ZIP generation for the current translation set. 88 | * 89 | * Adds a single cron event to generate the ZIP archive after a short amount of time. 90 | * 91 | * @since 3.0.0 92 | */ 93 | public function schedule_generation(): void { 94 | $translation_set_id = (int) $this->translation_set->id; 95 | 96 | /** 97 | * Filters the delay for scheduled language pack generation. 98 | * 99 | * @since 3.0.0 100 | * 101 | * @param int $delay Delay in minutes. Default is 5 minutes. 102 | * @param \GP_Translation_Set $translation_set Translation set the ZIP generation will be scheduled for. 103 | */ 104 | $delay = (int) apply_filters( 'traduttore.generate_zip_delay', MINUTE_IN_SECONDS * 5, $this->translation_set ); 105 | 106 | $next_schedule = wp_next_scheduled( 'traduttore.generate_zip', [ $translation_set_id ] ); 107 | 108 | if ( $next_schedule ) { 109 | wp_unschedule_event( $next_schedule, 'traduttore.generate_zip', [ $translation_set_id ] ); 110 | } 111 | 112 | wp_schedule_single_event( time() + $delay, 'traduttore.generate_zip', [ $translation_set_id ] ); 113 | } 114 | 115 | /** 116 | * Generates and caches a ZIP file for a translation set. 117 | * 118 | * @since 2.0.0 119 | * 120 | * @global WP_Filesystem_Base $wp_filesystem 121 | * 122 | * @return bool True on success, false on failure. 123 | */ 124 | public function generate_zip_file(): bool { 125 | /** @var \WP_Filesystem_Base|null $wp_filesystem */ 126 | global $wp_filesystem; 127 | 128 | if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { 129 | require_once ABSPATH . '/wp-admin/includes/admin.php'; 130 | 131 | if ( ! \WP_Filesystem() ) { 132 | return false; 133 | } 134 | } 135 | 136 | if ( ! $wp_filesystem ) { 137 | return false; 138 | } 139 | 140 | // Make sure the cache directory exists. 141 | if ( ! is_dir( static::get_cache_dir() ) ) { 142 | $wp_filesystem->mkdir( static::get_cache_dir(), FS_CHMOD_DIR ); 143 | } 144 | 145 | $export = new Export( $this->translation_set ); 146 | 147 | $files_for_zip = $export->export_strings(); 148 | 149 | if ( ! $files_for_zip ) { 150 | return false; 151 | } 152 | 153 | $zip = new ZipArchive(); 154 | 155 | $temp_zip_file = wp_tempnam( $this->get_zip_filename() ); 156 | 157 | if ( $zip->open( $temp_zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) === true ) { 158 | foreach ( $files_for_zip as $file_name => $temp_file ) { 159 | $zip->addFile( $temp_file, $file_name ); 160 | } 161 | 162 | $zip->close(); 163 | } 164 | 165 | $wp_filesystem->move( $temp_zip_file, $this->get_zip_path(), true ); 166 | 167 | foreach ( $files_for_zip as $temp_file ) { 168 | $wp_filesystem->delete( $temp_file ); 169 | } 170 | 171 | $last_modified = $this->translation_set->last_modified(); 172 | 173 | if ( $last_modified ) { 174 | $last_modified = new DateTime( $last_modified, new DateTimeZone( 'UTC' ) ); 175 | } else { 176 | $last_modified = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); 177 | } 178 | 179 | gp_update_meta( $this->translation_set->id, static::BUILD_TIME_KEY, $last_modified->format( DATE_ATOM ), 'translation_set' ); 180 | 181 | /** 182 | * Fires after a language pack for a given translation set has been generated. 183 | * 184 | * @since 3.0.0 185 | * 186 | * @param string $file Path to the generated language pack. 187 | * @param string $url URL to the generated language pack. 188 | * @param \GP_Translation_Set $translation_set Translation set the language pack is for. 189 | * @param \Required\Traduttore\Project $project The translation set's project. 190 | */ 191 | do_action( 'traduttore.zip_generated', $this->get_zip_path(), $this->get_zip_url(), $this->translation_set, $this->project ); 192 | 193 | return true; 194 | } 195 | 196 | /** 197 | * Removes the ZIP file for a translation set. 198 | * 199 | * @since 3.0.0 200 | * 201 | * @global WP_Filesystem_Base $wp_filesystem 202 | * 203 | * @return bool True on success, false on failure. 204 | */ 205 | public function remove_zip_file(): bool { 206 | if ( ! file_exists( $this->get_zip_path() ) ) { 207 | return false; 208 | } 209 | 210 | /** @var \WP_Filesystem_Base|null $wp_filesystem */ 211 | global $wp_filesystem; 212 | 213 | if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { 214 | require_once ABSPATH . '/wp-admin/includes/admin.php'; 215 | 216 | if ( ! \WP_Filesystem() ) { 217 | return false; 218 | } 219 | } 220 | 221 | if ( ! $wp_filesystem ) { 222 | return false; 223 | } 224 | 225 | $success = $wp_filesystem->rmdir( $this->get_zip_path(), true ); 226 | 227 | if ( $success ) { 228 | gp_update_meta( $this->translation_set->id, static::BUILD_TIME_KEY, '', 'translation_set' ); 229 | } 230 | 231 | return $success; 232 | } 233 | 234 | /** 235 | * Returns the name of the ZIP file without the path. 236 | * 237 | * @since 2.0.0 238 | * 239 | * @return string ZIP filename. 240 | */ 241 | protected function get_zip_filename(): string { 242 | $slug = str_replace( '/', '-', $this->project->get_slug() ); 243 | $version = $this->project->get_version(); 244 | 245 | if ( $version ) { 246 | return sprintf( 247 | '%1$s-%2$s-%3$s.zip', 248 | $slug, 249 | $this->locale->wp_locale, 250 | $version 251 | ); 252 | } 253 | 254 | return sprintf( 255 | '%1$s-%2$s.zip', 256 | $slug, 257 | $this->locale->wp_locale 258 | ); 259 | } 260 | 261 | /** 262 | * Returns the last ZIP build time for a given translation set. 263 | * 264 | * @since 2.0.0 265 | * 266 | * @return \DateTime Last build time. 267 | */ 268 | public function get_last_build_time(): ?DateTime { 269 | /** 270 | * Build time. 271 | * 272 | * @var string|false $meta 273 | */ 274 | $meta = gp_get_meta( 'translation_set', $this->translation_set->id, static::BUILD_TIME_KEY ); 275 | 276 | return $meta ? new DateTime( $meta, new DateTimeZone( 'UTC' ) ) : null; 277 | } 278 | 279 | /** 280 | * Returns the full URL to the ZIP file. 281 | * 282 | * @since 2.0.0 283 | * 284 | * @return string ZIP file URL. 285 | */ 286 | public function get_zip_url(): string { 287 | $url = content_url( self::CACHE_DIR ); 288 | 289 | /** 290 | * Filters the path to Traduttore's cache directory. 291 | * 292 | * Useful when language packs should be stored somewhere else. 293 | * 294 | * @since 3.0.0 295 | * 296 | * @param string $url Cache directory URL. 297 | */ 298 | $url = apply_filters( 'traduttore.content_url', $url ); 299 | 300 | return sprintf( 301 | '%1$s/%2$s', 302 | $url, 303 | $this->get_zip_filename() 304 | ); 305 | } 306 | 307 | /** 308 | * Returns the full path to the ZIP file. 309 | * 310 | * @since 2.0.0 311 | * 312 | * @return string ZIP file path. 313 | */ 314 | public function get_zip_path(): string { 315 | return sprintf( 316 | '%1$s/%2$s', 317 | static::get_cache_dir(), 318 | $this->get_zip_filename() 319 | ); 320 | } 321 | 322 | /** 323 | * Returns the full path to the directory where language packs are stored. 324 | * 325 | * @since 2.0.0 326 | * 327 | * @return string Cache directory path. 328 | */ 329 | public static function get_cache_dir(): string { 330 | $dir = sprintf( 331 | '%1$s/%2$s', 332 | WP_CONTENT_DIR, 333 | self::CACHE_DIR 334 | ); 335 | 336 | /** 337 | * Filters the path to Traduttore's cache directory. 338 | * 339 | * Useful when language packs should be stored somewhere else. 340 | * 341 | * @since 3.0.0 342 | * 343 | * @param string $dir Cache directory path. 344 | */ 345 | return apply_filters( 'traduttore.content_dir', $dir ); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | inferPrivatePropertyTypeFromConstructor: true 4 | bootstrapFiles: 5 | - tests/phpstan/bootstrap.php 6 | - vendor/wordpress-plugin/glotpress/gp-includes/route.php 7 | - vendor/wordpress-plugin/glotpress/gp-includes/routes/_main.php 8 | scanFiles: 9 | - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php 10 | - vendor/php-stubs/wordpress-tests-stubs/wordpress-tests-stubs.php 11 | scanDirectories: 12 | - tests/phpstan/stubs/ 13 | - vendor/wordpress-plugin/glotpress/gp-includes/ 14 | - vendor/wordpress-plugin/glotpress/locales/ 15 | paths: 16 | - inc/ 17 | - tests/phpunit 18 | -------------------------------------------------------------------------------- /traduttore.php: -------------------------------------------------------------------------------- 1 | init(); 61 | } 62 | 63 | add_action( 'plugins_loaded', __NAMESPACE__ . '\init', 1 ); 64 | 65 | if ( class_exists( '\WP_CLI' ) ) { 66 | if ( class_exists( '\WP_CLI\Dispatcher\CommandNamespace' ) ) { 67 | WP_CLI::add_command( 'traduttore', CLI\CommandNamespace::class ); 68 | } 69 | 70 | WP_CLI::add_command( 'traduttore info', CLI\InfoCommand::class ); 71 | WP_CLI::add_command( 'traduttore project', CLI\ProjectCommand::class ); 72 | WP_CLI::add_command( 'traduttore project cache', CLI\CacheCommand::class ); 73 | WP_CLI::add_command( 'traduttore language-pack', CLI\LanguagePackCommand::class ); 74 | } 75 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 | delete( $file_or_folder, true, is_dir( $file_or_folder ) ? 'd' : 'f' ); 23 | }, 24 | glob( get_temp_dir() . 'traduttore-*' ) 25 | ); 26 | 27 | $wp_filesystem->rmdir( ZipProvider::get_cache_dir(), true ); 28 | } 29 | 30 | /* @var \wpdb $wpdb */ 31 | global $wpdb; 32 | 33 | $traduttore_meta_key_prefix = '_traduttore_'; 34 | $traduttore_query = $wpdb->prepare( 35 | "DELETE FROM `$wpdb->gp_meta` WHERE `meta_key` LIKE %s ", 36 | $wpdb->esc_like( $traduttore_meta_key_prefix ) . '%' 37 | ); 38 | 39 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 40 | $wpdb->query( $traduttore_query ); 41 | --------------------------------------------------------------------------------