Skip to content

Commit f4eac6d

Browse files
dereuromarkclaude
andauthored
Add standalone POT updater tool for CI integration (#36)
* Add standalone POT updater tool for CI integration. - CakeStringExtractor wraps CakePHP's I18nExtractCommand - PotParser/PotWriter/PotComparator for POT file handling - CLI script at scripts/pot-updater.php - Supports --dry-run, --update, --fail-on-diff for CI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Delete docs/pot-updater-plan.md * Fix PHPCS doc block errors Simplify multi-line array shape annotations to single-line format. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix autoloader path and add scripts README - Fix vendor/autoload.php path (was missing vendor/) - Add README.md with installation and usage docs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Skip POT write if only timestamp changed - Add needsUpdate() to check if actual content differs - Compare content after stripping POT-Creation-Date - Show accurate message when no write is needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Docs. --------- Co-authored-by: Claude <[email protected]>
1 parent 52db7ea commit f4eac6d

File tree

9 files changed

+1699
-0
lines changed

9 files changed

+1699
-0
lines changed

scripts/README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Scripts
2+
3+
## POT Updater
4+
5+
Standalone tool for checking and updating POT files in CakePHP plugins.
6+
7+
### Installation
8+
9+
Add to your plugin's `composer.json`:
10+
11+
```json
12+
{
13+
"require-dev": {
14+
"dereuromark/cakephp-translate": "^2.0"
15+
}
16+
}
17+
```
18+
19+
Then run:
20+
```bash
21+
composer update
22+
```
23+
24+
### Usage
25+
26+
Run from your plugin's root directory:
27+
28+
```bash
29+
# Check for differences (dry-run, default)
30+
php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php
31+
32+
# Update the POT file
33+
php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --update
34+
35+
# CI mode: fail if POT is out of date
36+
php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --fail-on-diff
37+
```
38+
39+
### Composer Scripts
40+
41+
Add to your plugin's `composer.json` for convenience:
42+
43+
```json
44+
{
45+
"scripts": {
46+
"pot-setup": "cp composer.json composer.backup && composer require --dev dereuromark/cakephp-translate && mv composer.backup composer.json",
47+
"pot-check": "php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --fail-on-diff",
48+
"pot-update": "php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --update"
49+
}
50+
}
51+
```
52+
53+
Then use:
54+
```bash
55+
composer pot-check # Check if POT is up to date
56+
composer pot-update # Regenerate POT file
57+
```
58+
59+
### Options
60+
61+
| Option | Description |
62+
|--------|-------------|
63+
| `--dry-run` | Compare and report differences without writing (default) |
64+
| `--update` | Actually update the POT file(s) |
65+
| `--verbose`, `-v` | Show detailed output including all strings |
66+
| `--quiet`, `-q` | Only output errors, suitable for CI |
67+
| `--path=<path>` | Custom paths to scan, comma-separated (default: `src,templates`) |
68+
| `--output=<path>` | Custom output path (default: `resources/locales`) |
69+
| `--domain=<name>` | Expected domain name (default: auto-detect from plugin name) |
70+
| `--fail-on-diff` | Exit with code 1 if differences found (for CI) |
71+
| `--ignore-references` | Ignore comment/reference differences |
72+
| `--help`, `-h` | Show help message |
73+
74+
### Exit Codes
75+
76+
| Code | Description |
77+
|------|-------------|
78+
| 0 | Success (POT file is up to date or was updated successfully) |
79+
| 1 | Differences found (when using `--fail-on-diff`) |
80+
| 2 | Error (could not read/write files, invalid options) |
81+
82+
### CI Integration
83+
84+
#### GitHub Actions
85+
86+
```yaml
87+
- name: Check POT file is up to date
88+
run: composer pot-check
89+
```
90+
91+
#### GitLab CI
92+
93+
```yaml
94+
pot-check:
95+
script:
96+
- composer pot-check
97+
```

scripts/pot-updater.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env php
2+
<?php
3+
/**
4+
* POT Updater - Standalone tool for checking/updating POT files
5+
*
6+
* Usage: php pot-updater.php [options]
7+
*
8+
* Options:
9+
* --dry-run Compare and report differences without writing (default)
10+
* --update Actually update the POT file(s)
11+
* --verbose, -v Show detailed output including all strings
12+
* --quiet, -q Only output errors, suitable for CI
13+
* --path=<path> Custom paths to scan, comma-separated (default: src,templates)
14+
* --output=<path> Custom output path (default: resources/locales)
15+
* --domain=<name> Expected domain name (default: auto-detect from plugin name)
16+
* --fail-on-diff Exit with code 1 if differences found (for CI)
17+
* --ignore-references Ignore comment/reference differences
18+
* --help, -h Show this help message
19+
*
20+
* Examples:
21+
* php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php
22+
* php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --update
23+
* php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --dry-run --fail-on-diff
24+
* php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --domain=queue
25+
*
26+
* @copyright Copyright (c) Mark Scherer
27+
* @license MIT
28+
*/
29+
30+
// Find autoloader
31+
$autoloaders = [
32+
// When running from vendor directory of another project
33+
dirname(__DIR__, 4) . '/vendor/autoload.php',
34+
// When running from plugin root during development
35+
dirname(__DIR__) . '/vendor/autoload.php',
36+
];
37+
38+
$autoloaderFound = false;
39+
foreach ($autoloaders as $autoloader) {
40+
if (file_exists($autoloader)) {
41+
require_once $autoloader;
42+
$autoloaderFound = true;
43+
break;
44+
}
45+
}
46+
47+
if (!$autoloaderFound) {
48+
fwrite(STDERR, "Error: Could not find Composer autoloader.\n");
49+
fwrite(STDERR, "Make sure you have run 'composer install'.\n");
50+
exit(2);
51+
}
52+
53+
use Translate\PotUpdater\PotUpdater;
54+
55+
// Show help if requested
56+
if (in_array('--help', $argv, true) || in_array('-h', $argv, true)) {
57+
$help = <<<'HELP'
58+
POT Updater - Standalone tool for checking/updating POT files
59+
60+
Usage: php pot-updater.php [options]
61+
62+
Options:
63+
--dry-run Compare and report differences without writing (default)
64+
--update Actually update the POT file(s)
65+
--verbose, -v Show detailed output including all strings
66+
--quiet, -q Only output errors, suitable for CI
67+
--path=<path> Custom paths to scan, comma-separated (default: src,templates)
68+
--output=<path> Custom output path (default: resources/locales)
69+
--domain=<name> Expected domain name (default: auto-detect from plugin name)
70+
--fail-on-diff Exit with code 1 if differences found (for CI)
71+
--ignore-references Ignore comment/reference differences
72+
--help, -h Show this help message
73+
74+
Examples:
75+
php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php
76+
php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --update
77+
php vendor/dereuromark/cakephp-translate/scripts/pot-updater.php --dry-run --fail-on-diff
78+
79+
Exit Codes:
80+
0 - Success (POT file is up to date or was updated successfully)
81+
1 - Differences found (when using --fail-on-diff)
82+
2 - Error (could not read/write files, invalid options)
83+
84+
HELP;
85+
echo $help;
86+
exit(0);
87+
}
88+
89+
// Determine plugin path (current working directory)
90+
$pluginPath = getcwd();
91+
92+
if ($pluginPath === false) {
93+
fwrite(STDERR, "Error: Could not determine current working directory.\n");
94+
exit(2);
95+
}
96+
97+
// Run the updater
98+
$updater = new PotUpdater($pluginPath);
99+
exit($updater->run($argv));
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
namespace Translate\PotUpdater;
4+
5+
use Cake\Command\I18nExtractCommand;
6+
use Cake\Console\Arguments;
7+
use Cake\Console\ConsoleIo;
8+
use Cake\Console\TestSuite\StubConsoleInput;
9+
use Cake\Console\TestSuite\StubConsoleOutput;
10+
use Cake\Core\App;
11+
12+
/**
13+
* String extractor that wraps CakePHP's I18nExtractCommand.
14+
*
15+
* This ensures we use the exact same extraction logic as CakePHP core,
16+
* avoiding any divergence in behavior.
17+
*/
18+
class CakeStringExtractor extends I18nExtractCommand {
19+
20+
/**
21+
* Extracted translations (captured before file write)
22+
*
23+
* @var array<string, array<string, array<string, array>>>
24+
*/
25+
protected array $extractedTranslations = [];
26+
27+
/**
28+
* Base path for calculating relative references
29+
*/
30+
protected string $basePath = '';
31+
32+
/**
33+
* Extract strings from given paths
34+
*
35+
* @param array<string> $paths Paths to scan
36+
* @param string $domain Expected domain name
37+
* @param string|null $basePath Base path for relative references
38+
* @return array<string, array<string, array{
39+
* msgid: string,
40+
* msgid_plural: string|null,
41+
* msgctxt: string|null,
42+
* references: array<string>,
43+
* comments: array<string>
44+
* }>>
45+
*/
46+
public function extractStrings(array $paths, string $domain = 'default', ?string $basePath = null): array {
47+
// Define required constants if not already defined
48+
$firstPath = $paths[0] ?? (getcwd() ?: '.');
49+
$this->basePath = $basePath ?? dirname($firstPath);
50+
$this->ensureConstants($this->basePath);
51+
// Reset state
52+
$this->_paths = $paths;
53+
$this->_files = [];
54+
$this->_translations = [];
55+
$this->extractedTranslations = [];
56+
57+
// Search for files
58+
$this->_searchFiles();
59+
60+
// Create stub IO to suppress output
61+
$out = new StubConsoleOutput();
62+
$err = new StubConsoleOutput();
63+
$in = new StubConsoleInput([]);
64+
$io = new ConsoleIo($out, $err, $in);
65+
66+
// Create stub arguments
67+
$args = new Arguments([], [], []);
68+
69+
// Extract tokens (this populates $this->_translations)
70+
$this->_extractTokens($args, $io);
71+
72+
// Convert to our format
73+
return $this->convertTranslations();
74+
}
75+
76+
/**
77+
* Convert CakePHP's translation format to our format
78+
*
79+
* @return array<string, array<string, array{
80+
* msgid: string,
81+
* msgid_plural: string|null,
82+
* msgctxt: string|null,
83+
* references: array<string>,
84+
* comments: array<string>
85+
* }>>
86+
*/
87+
protected function convertTranslations(): array {
88+
$result = [];
89+
90+
foreach ($this->_translations as $domain => $messages) {
91+
$result[$domain] = [];
92+
93+
foreach ($messages as $msgid => $contexts) {
94+
foreach ($contexts as $context => $details) {
95+
// Build key (context + msgid)
96+
$key = $context !== '' ? "{$context}\x04{$msgid}" : $msgid;
97+
98+
// Convert references
99+
$references = [];
100+
if (!empty($details['references'])) {
101+
foreach ($details['references'] as $file => $lines) {
102+
foreach (array_unique($lines) as $line) {
103+
$references[] = $file . ':' . $line;
104+
}
105+
}
106+
}
107+
108+
$result[$domain][$key] = [
109+
'msgid' => $msgid,
110+
'msgid_plural' => $details['msgid_plural'] ?: null,
111+
'msgctxt' => $context !== '' ? $context : null,
112+
'references' => $references,
113+
'comments' => [],
114+
];
115+
}
116+
}
117+
}
118+
119+
return $result;
120+
}
121+
122+
/**
123+
* Get strings for a specific domain
124+
*
125+
* @param array<string> $paths Paths to scan
126+
* @param string $domain Domain name
127+
* @return array<string, array{
128+
* msgid: string,
129+
* msgid_plural: string|null,
130+
* msgctxt: string|null,
131+
* references: array<string>,
132+
* comments: array<string>
133+
* }>
134+
*/
135+
public function extractStringsForDomain(array $paths, string $domain): array {
136+
$all = $this->extractStrings($paths, $domain);
137+
138+
return $all[$domain] ?? [];
139+
}
140+
141+
/**
142+
* Ensure CakePHP constants are defined
143+
*
144+
* @param string $basePath Base path to use for ROOT/APP
145+
* @return void
146+
*/
147+
protected function ensureConstants(string $basePath): void {
148+
if (!defined('ROOT')) {
149+
define('ROOT', $basePath);
150+
}
151+
if (!defined('APP')) {
152+
define('APP', $basePath . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR);
153+
}
154+
if (!defined('CAKE')) {
155+
// Point to CakePHP source - find it via autoloader
156+
$cakePath = dirname((string)(new \ReflectionClass(App::class))->getFileName(), 2);
157+
define('CAKE', $cakePath . DIRECTORY_SEPARATOR);
158+
}
159+
if (!defined('CAKE_CORE_INCLUDE_PATH')) {
160+
define('CAKE_CORE_INCLUDE_PATH', dirname(CAKE));
161+
}
162+
}
163+
164+
}

0 commit comments

Comments
 (0)