diff --git a/features/updatepo.feature b/features/updatepo.feature index 865c1ba9..0111fdcb 100644 --- a/features/updatepo.feature +++ b/features/updatepo.feature @@ -466,6 +466,148 @@ Feature: Update existing PO files from a POT file "X-Domain: foo-plugin\n" """ + 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: + """ + # 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 --no-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 by default + 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" + """ + Scenario: Updates PO-Revision-Date when updating a PO file 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 0d0b1f01..a1c8b937 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. * + * [--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. + * Use `--no-purge` to preserve obsolete translations (marked with #~) and 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 --no-purge + * Success: Updated 3 files. + * * # Shows message when some files don't need updating. * $ wp i18n update-po example-plugin.pot languages * Success: Updated 2 files. 1 file unchanged. @@ -73,6 +82,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; + + $purge = Utils\get_flag_value( $assoc_args, 'purge', true ); + + if ( $purge ) { + // By default, remove obsolete entries and replace translator comments + $merge_flags |= Merge::REMOVE | Merge::COMMENTS_THEIRS; + } + $updated_count = 0; $unchanged_count = 0; /** @var DirectoryIterator $file */ @@ -86,17 +105,27 @@ public function __invoke( $args, $assoc_args ) { continue; } + // Preserve file-level comments when --no-purge is set + $file_comments = ''; + if ( ! $purge ) { + $file_comments = $this->extract_file_comments( $file->getPathname() ); + } $po_translations = Translations::fromPoFile( $file->getPathname() ); $original_translations = clone $po_translations; $po_translations->mergeWith( $pot_translations, - Merge::ADD | Merge::REMOVE | Merge::COMMENTS_THEIRS | Merge::EXTRACTED_COMMENTS_THEIRS | Merge::REFERENCES_THEIRS | Merge::DOMAIN_OVERRIDE + $merge_flags ); // Check if the translations actually changed by comparing the objects. $has_changes = $this->translations_differ( $original_translations, $po_translations ); + // When using --no-purge, file-level comments being restored counts as a change. + if ( ! $purge && ! empty( $file_comments ) ) { + $has_changes = true; + } + // Update PO-Revision-Date to current date and time in UTC. // Uses gmdate() for consistency across different server timezones. $po_translations->setHeader( 'PO-Revision-Date', gmdate( 'Y-m-d\TH:i:sP' ) ); @@ -109,6 +138,13 @@ public function __invoke( $args, $assoc_args ) { continue; } + // Restore file-level comments when --no-purge is set + if ( ! $purge && ! empty( $file_comments ) ) { + if ( ! $this->restore_file_comments( $file->getPathname(), $file_comments ) ) { + WP_CLI::warning( sprintf( 'Could not restore file-level comments for %s', $file->getPathname() ) ); + } + } + if ( $has_changes ) { ++$updated_count; } else { @@ -126,6 +162,74 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::success( implode( '. ', $message_parts ) . '.' ); } + /** + * 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; + + // 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; + } + /** * Check if two Translations objects differ. * @@ -216,6 +320,14 @@ private function reorder_translations( Translations $po_translations, Translatio } } + // Add any remaining translations from PO that aren't in POT (e.g., obsolete/disabled translations). + foreach ( $po_translations as $po_entry ) { + // Check if this entry is already in the ordered set. + if ( ! $ordered->find( $po_entry ) ) { + $ordered[] = $po_entry->getClone(); + } + } + return $ordered; } }