From 7cdada5da773e51ab52251eda880d7e089df2294 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:42:44 +0000 Subject: [PATCH 1/6] Initial plan From 9e2589b13bbe03f5a2e6e8a5089c2dfe62367e9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:08:41 +0000 Subject: [PATCH 2/6] Add --skip-purge flag to preserve obsolete entries and comments Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/UpdatePoCommand.php | 87 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/UpdatePoCommand.php b/src/UpdatePoCommand.php index 5af6b996..61be5a91 100644 --- a/src/UpdatePoCommand.php +++ b/src/UpdatePoCommand.php @@ -27,6 +27,11 @@ class UpdatePoCommand extends WP_CLI_Command { * : PO file to update or a directory containing multiple PO files. * Defaults to all PO files in the source directory. * + * [--skip-purge] + * : Prevent removal of obsolete strings and preserve translator comments. + * By default, strings not found in the POT file are removed, and translator comments are replaced with those from the POT file. + * This flag keeps obsolete translations (marked with #~) and preserves existing translator comments like copyright notices. + * * ## EXAMPLES * * # Update all PO files from a POT file in the current directory. @@ -41,6 +46,10 @@ class UpdatePoCommand extends WP_CLI_Command { * $ wp i18n update-po example-plugin.pot languages * Success: Updated 2 files. * + * # Update PO files while keeping obsolete strings and translator comments. + * $ wp i18n update-po example-plugin.pot --skip-purge + * Success: Updated 3 files. + * * @when before_wp_load * * @throws WP_CLI\ExitException @@ -69,6 +78,16 @@ public function __invoke( $args, $assoc_args ) { $pot_translations = Translations::fromPoFile( $source ); + // Build merge flags based on options + $merge_flags = Merge::ADD | Merge::EXTRACTED_COMMENTS_THEIRS | Merge::REFERENCES_THEIRS | Merge::DOMAIN_OVERRIDE; + + $skip_purge = Utils\get_flag_value( $assoc_args, 'skip-purge', false ); + + if ( ! $skip_purge ) { + // By default, remove obsolete entries and replace translator comments + $merge_flags |= Merge::REMOVE | Merge::COMMENTS_THEIRS; + } + $result_count = 0; /** @var DirectoryIterator $file */ foreach ( $files as $file ) { @@ -81,10 +100,16 @@ public function __invoke( $args, $assoc_args ) { continue; } + // Preserve file-level comments when --skip-purge is set + $file_comments = ''; + if ( $skip_purge ) { + $file_comments = $this->extract_file_comments( $file->getPathname() ); + } + $po_translations = Translations::fromPoFile( $file->getPathname() ); $po_translations->mergeWith( $pot_translations, - Merge::ADD | Merge::REMOVE | Merge::COMMENTS_THEIRS | Merge::EXTRACTED_COMMENTS_THEIRS | Merge::REFERENCES_THEIRS | Merge::DOMAIN_OVERRIDE + $merge_flags ); if ( ! $po_translations->toPoFile( $file->getPathname() ) ) { @@ -92,9 +117,69 @@ public function __invoke( $args, $assoc_args ) { continue; } + // Restore file-level comments when --skip-purge is set + if ( $skip_purge && ! empty( $file_comments ) ) { + $this->restore_file_comments( $file->getPathname(), $file_comments ); + } + ++$result_count; } WP_CLI::success( sprintf( 'Updated %d %s.', $result_count, Utils\pluralize( 'file', $result_count ) ) ); } + + /** + * Extract file-level comments from a PO file. + * + * These are comments that appear before the first msgid in the file. + * + * @param string $file_path Path to the PO file. + * @return string The file-level comments. + */ + private function extract_file_comments( $file_path ) { + $content = file_get_contents( $file_path ); + if ( false === $content ) { + return ''; + } + + $lines = explode( "\n", $content ); + $file_comments = []; + $found_msgid = false; + + foreach ( $lines as $line ) { + $trimmed = trim( $line ); + + // Stop when we hit the first msgid + if ( preg_match( '/^msgid\s/', $trimmed ) ) { + $found_msgid = true; + break; + } + + // Collect comment lines + if ( preg_match( '/^#([^.,:~]|$)/', $trimmed ) ) { + $file_comments[] = $line; + } + } + + return $found_msgid && ! empty( $file_comments ) ? implode( "\n", $file_comments ) . "\n" : ''; + } + + /** + * Restore file-level comments to a PO file. + * + * @param string $file_path Path to the PO file. + * @param string $comments The file-level comments to restore. + * @return bool True on success, false on failure. + */ + private function restore_file_comments( $file_path, $comments ) { + $content = file_get_contents( $file_path ); + if ( false === $content ) { + return false; + } + + // Prepend the comments to the file content + $updated_content = $comments . $content; + + return false !== file_put_contents( $file_path, $updated_content ); + } } From af41370a662c86060e8f0b1cd24d27d614acb45d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:10:45 +0000 Subject: [PATCH 3/6] Add test scenarios for --skip-purge flag Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/updatepo.feature | 142 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/features/updatepo.feature b/features/updatepo.feature index 6aba5e30..612f98b1 100644 --- a/features/updatepo.feature +++ b/features/updatepo.feature @@ -465,3 +465,145 @@ Feature: Update existing PO files from a POT file """ "X-Domain: foo-plugin\n" """ + + Scenario: Preserves obsolete translations and file-level comments with --skip-purge + Given an empty foo-plugin directory + And a foo-plugin/foo-plugin.pot file: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + + #: foo-plugin.php:1 + msgid "Some string" + msgstr "" + """ + And a foo-plugin/foo-plugin-de_DE.po file: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "Language: de_DE\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + "Plural-Forms: nplurals=2; plural=(n != 1);\n" + + #: foo-plugin.php:1 + msgid "Some string" + msgstr "Some translated string" + + #~ msgid "Obsolete string" + #~ msgstr "Veralteter String" + """ + + When I run `wp i18n update-po foo-plugin/foo-plugin.pot foo-plugin/foo-plugin-de_DE.po --skip-purge` + Then STDOUT should be: + """ + Success: Updated 1 file. + """ + And STDERR should be empty + And the foo-plugin/foo-plugin-de_DE.po file should contain: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + """ + And the foo-plugin/foo-plugin-de_DE.po file should contain: + """ + #~ msgid "Obsolete string" + #~ msgstr "Veralteter String" + """ + And the foo-plugin/foo-plugin-de_DE.po file should contain: + """ + #: foo-plugin.php:1 + msgid "Some string" + msgstr "Some translated string" + """ + + Scenario: Removes obsolete translations and comments without --skip-purge + Given an empty foo-plugin directory + And a foo-plugin/foo-plugin.pot file: + """ + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + + #: foo-plugin.php:1 + msgid "Some string" + msgstr "" + """ + And a foo-plugin/foo-plugin-de_DE.po file: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "Language: de_DE\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + "Plural-Forms: nplurals=2; plural=(n != 1);\n" + + #: foo-plugin.php:1 + msgid "Some string" + msgstr "Some translated string" + + #~ msgid "Obsolete string" + #~ msgstr "Veralteter String" + """ + + When I run `wp i18n update-po foo-plugin/foo-plugin.pot foo-plugin/foo-plugin-de_DE.po` + Then STDOUT should be: + """ + Success: Updated 1 file. + """ + And STDERR should be empty + And the foo-plugin/foo-plugin-de_DE.po file should not contain: + """ + # Copyright (C) 2018 Foo Plugin + """ + And the foo-plugin/foo-plugin-de_DE.po file should not contain: + """ + #~ msgid "Obsolete string" + """ + And the foo-plugin/foo-plugin-de_DE.po file should contain: + """ + #: foo-plugin.php:1 + msgid "Some string" + msgstr "Some translated string" + """ From 765140565234cf68711a6d7beba8204100096d25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:14:30 +0000 Subject: [PATCH 4/6] Fix code review issues: remove trailing whitespace and use atomic file operations Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/UpdatePoCommand.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/UpdatePoCommand.php b/src/UpdatePoCommand.php index 61be5a91..72a688d2 100644 --- a/src/UpdatePoCommand.php +++ b/src/UpdatePoCommand.php @@ -80,9 +80,9 @@ public function __invoke( $args, $assoc_args ) { // Build merge flags based on options $merge_flags = Merge::ADD | Merge::EXTRACTED_COMMENTS_THEIRS | Merge::REFERENCES_THEIRS | Merge::DOMAIN_OVERRIDE; - + $skip_purge = Utils\get_flag_value( $assoc_args, 'skip-purge', false ); - + if ( ! $skip_purge ) { // By default, remove obsolete entries and replace translator comments $merge_flags |= Merge::REMOVE | Merge::COMMENTS_THEIRS; @@ -148,7 +148,7 @@ private function extract_file_comments( $file_path ) { foreach ( $lines as $line ) { $trimmed = trim( $line ); - + // Stop when we hit the first msgid if ( preg_match( '/^msgid\s/', $trimmed ) ) { $found_msgid = true; @@ -180,6 +180,19 @@ private function restore_file_comments( $file_path, $comments ) { // Prepend the comments to the file content $updated_content = $comments . $content; - return false !== file_put_contents( $file_path, $updated_content ); + // Use atomic file operation with temporary file + $temp_file = $file_path . '.tmp'; + if ( false === file_put_contents( $temp_file, $updated_content ) ) { + return false; + } + + // Rename is atomic on most filesystems + if ( ! rename( $temp_file, $file_path ) ) { + // Clean up temp file on failure + @unlink( $temp_file ); + return false; + } + + return true; } } From 64b01d38159eb50a20ef1c97d561376cc5ba7d9f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 3 Nov 2025 18:32:59 +0100 Subject: [PATCH 5/6] Lint fix --- src/UpdatePoCommand.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/UpdatePoCommand.php b/src/UpdatePoCommand.php index 72a688d2..b6846c96 100644 --- a/src/UpdatePoCommand.php +++ b/src/UpdatePoCommand.php @@ -142,9 +142,9 @@ private function extract_file_comments( $file_path ) { return ''; } - $lines = explode( "\n", $content ); - $file_comments = []; - $found_msgid = false; + $lines = explode( "\n", $content ); + $file_comments = []; + $found_msgid = false; foreach ( $lines as $line ) { $trimmed = trim( $line ); From 86fabf42ac818dec1433ab2c932a44b6055a26d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:51:32 +0000 Subject: [PATCH 6/6] Rename flag from --skip-purge to --purge with inverted logic Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/updatepo.feature | 6 +++--- src/UpdatePoCommand.php | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/features/updatepo.feature b/features/updatepo.feature index 612f98b1..d648058b 100644 --- a/features/updatepo.feature +++ b/features/updatepo.feature @@ -466,7 +466,7 @@ Feature: Update existing PO files from a POT file "X-Domain: foo-plugin\n" """ - Scenario: Preserves obsolete translations and file-level comments with --skip-purge + Scenario: Preserves obsolete translations and file-level comments with --no-purge Given an empty foo-plugin directory And a foo-plugin/foo-plugin.pot file: """ @@ -516,7 +516,7 @@ Feature: Update existing PO files from a POT file #~ msgstr "Veralteter String" """ - When I run `wp i18n update-po foo-plugin/foo-plugin.pot foo-plugin/foo-plugin-de_DE.po --skip-purge` + When I run `wp i18n update-po foo-plugin/foo-plugin.pot foo-plugin/foo-plugin-de_DE.po --no-purge` Then STDOUT should be: """ Success: Updated 1 file. @@ -539,7 +539,7 @@ Feature: Update existing PO files from a POT file msgstr "Some translated string" """ - Scenario: Removes obsolete translations and comments without --skip-purge + Scenario: Removes obsolete translations and comments by default Given an empty foo-plugin directory And a foo-plugin/foo-plugin.pot file: """ diff --git a/src/UpdatePoCommand.php b/src/UpdatePoCommand.php index b6846c96..08822311 100644 --- a/src/UpdatePoCommand.php +++ b/src/UpdatePoCommand.php @@ -27,10 +27,10 @@ class UpdatePoCommand extends WP_CLI_Command { * : PO file to update or a directory containing multiple PO files. * Defaults to all PO files in the source directory. * - * [--skip-purge] - * : Prevent removal of obsolete strings and preserve translator comments. + * [--purge] + * : Remove obsolete strings and replace translator comments. Defaults to true. * By default, strings not found in the POT file are removed, and translator comments are replaced with those from the POT file. - * This flag keeps obsolete translations (marked with #~) and preserves existing translator comments like copyright notices. + * Use `--no-purge` to preserve obsolete translations (marked with #~) and existing translator comments like copyright notices. * * ## EXAMPLES * @@ -47,7 +47,7 @@ class UpdatePoCommand extends WP_CLI_Command { * Success: Updated 2 files. * * # Update PO files while keeping obsolete strings and translator comments. - * $ wp i18n update-po example-plugin.pot --skip-purge + * $ wp i18n update-po example-plugin.pot --no-purge * Success: Updated 3 files. * * @when before_wp_load @@ -81,9 +81,9 @@ public function __invoke( $args, $assoc_args ) { // Build merge flags based on options $merge_flags = Merge::ADD | Merge::EXTRACTED_COMMENTS_THEIRS | Merge::REFERENCES_THEIRS | Merge::DOMAIN_OVERRIDE; - $skip_purge = Utils\get_flag_value( $assoc_args, 'skip-purge', false ); + $purge = Utils\get_flag_value( $assoc_args, 'purge', true ); - if ( ! $skip_purge ) { + if ( $purge ) { // By default, remove obsolete entries and replace translator comments $merge_flags |= Merge::REMOVE | Merge::COMMENTS_THEIRS; } @@ -100,9 +100,9 @@ public function __invoke( $args, $assoc_args ) { continue; } - // Preserve file-level comments when --skip-purge is set + // Preserve file-level comments when --no-purge is set $file_comments = ''; - if ( $skip_purge ) { + if ( ! $purge ) { $file_comments = $this->extract_file_comments( $file->getPathname() ); } @@ -117,8 +117,8 @@ public function __invoke( $args, $assoc_args ) { continue; } - // Restore file-level comments when --skip-purge is set - if ( $skip_purge && ! empty( $file_comments ) ) { + // Restore file-level comments when --no-purge is set + if ( ! $purge && ! empty( $file_comments ) ) { $this->restore_file_comments( $file->getPathname(), $file_comments ); }