@@ -27,6 +27,11 @@ class UpdatePoCommand extends WP_CLI_Command {
2727 * : PO file to update or a directory containing multiple PO files.
2828 * Defaults to all PO files in the source directory.
2929 *
30+ * [--purge]
31+ * : Remove obsolete strings and replace translator comments. Defaults to true.
32+ * By default, strings not found in the POT file are removed, and translator comments are replaced with those from the POT file.
33+ * Use `--no-purge` to preserve obsolete translations (marked with #~) and existing translator comments like copyright notices.
34+ *
3035 * ## EXAMPLES
3136 *
3237 * # Update all PO files from a POT file in the current directory.
@@ -41,6 +46,10 @@ class UpdatePoCommand extends WP_CLI_Command {
4146 * $ wp i18n update-po example-plugin.pot languages
4247 * Success: Updated 2 files.
4348 *
49+ * # Update PO files while keeping obsolete strings and translator comments.
50+ * $ wp i18n update-po example-plugin.pot --no-purge
51+ * Success: Updated 3 files.
52+ *
4453 * # Shows message when some files don't need updating.
4554 * $ wp i18n update-po example-plugin.pot languages
4655 * Success: Updated 2 files. 1 file unchanged.
@@ -73,6 +82,16 @@ public function __invoke( $args, $assoc_args ) {
7382
7483 $ pot_translations = Translations::fromPoFile ( $ source );
7584
85+ // Build merge flags based on options
86+ $ merge_flags = Merge::ADD | Merge::EXTRACTED_COMMENTS_THEIRS | Merge::REFERENCES_THEIRS | Merge::DOMAIN_OVERRIDE ;
87+
88+ $ purge = Utils \get_flag_value ( $ assoc_args , 'purge ' , true );
89+
90+ if ( $ purge ) {
91+ // By default, remove obsolete entries and replace translator comments
92+ $ merge_flags |= Merge::REMOVE | Merge::COMMENTS_THEIRS ;
93+ }
94+
7695 $ updated_count = 0 ;
7796 $ unchanged_count = 0 ;
7897 /** @var DirectoryIterator $file */
@@ -86,17 +105,27 @@ public function __invoke( $args, $assoc_args ) {
86105 continue ;
87106 }
88107
108+ // Preserve file-level comments when --no-purge is set
109+ $ file_comments = '' ;
110+ if ( ! $ purge ) {
111+ $ file_comments = $ this ->extract_file_comments ( $ file ->getPathname () );
112+ }
89113 $ po_translations = Translations::fromPoFile ( $ file ->getPathname () );
90114 $ original_translations = clone $ po_translations ;
91115
92116 $ po_translations ->mergeWith (
93117 $ pot_translations ,
94- Merge:: ADD | Merge:: REMOVE | Merge:: COMMENTS_THEIRS | Merge:: EXTRACTED_COMMENTS_THEIRS | Merge:: REFERENCES_THEIRS | Merge:: DOMAIN_OVERRIDE
118+ $ merge_flags
95119 );
96120
97121 // Check if the translations actually changed by comparing the objects.
98122 $ has_changes = $ this ->translations_differ ( $ original_translations , $ po_translations );
99123
124+ // When using --no-purge, file-level comments being restored counts as a change.
125+ if ( ! $ purge && ! empty ( $ file_comments ) ) {
126+ $ has_changes = true ;
127+ }
128+
100129 // Update PO-Revision-Date to current date and time in UTC.
101130 // Uses gmdate() for consistency across different server timezones.
102131 $ po_translations ->setHeader ( 'PO-Revision-Date ' , gmdate ( 'Y-m-d\TH:i:sP ' ) );
@@ -109,6 +138,13 @@ public function __invoke( $args, $assoc_args ) {
109138 continue ;
110139 }
111140
141+ // Restore file-level comments when --no-purge is set
142+ if ( ! $ purge && ! empty ( $ file_comments ) ) {
143+ if ( ! $ this ->restore_file_comments ( $ file ->getPathname (), $ file_comments ) ) {
144+ WP_CLI ::warning ( sprintf ( 'Could not restore file-level comments for %s ' , $ file ->getPathname () ) );
145+ }
146+ }
147+
112148 if ( $ has_changes ) {
113149 ++$ updated_count ;
114150 } else {
@@ -126,6 +162,74 @@ public function __invoke( $args, $assoc_args ) {
126162 WP_CLI ::success ( implode ( '. ' , $ message_parts ) . '. ' );
127163 }
128164
165+ /**
166+ * Extract file-level comments from a PO file.
167+ *
168+ * These are comments that appear before the first msgid in the file.
169+ *
170+ * @param string $file_path Path to the PO file.
171+ * @return string The file-level comments.
172+ */
173+ private function extract_file_comments ( $ file_path ) {
174+ $ content = file_get_contents ( $ file_path );
175+ if ( false === $ content ) {
176+ return '' ;
177+ }
178+
179+ $ lines = explode ( "\n" , $ content );
180+ $ file_comments = [];
181+ $ found_msgid = false ;
182+
183+ foreach ( $ lines as $ line ) {
184+ $ trimmed = trim ( $ line );
185+
186+ // Stop when we hit the first msgid
187+ if ( preg_match ( '/^msgid\s/ ' , $ trimmed ) ) {
188+ $ found_msgid = true ;
189+ break ;
190+ }
191+
192+ // Collect comment lines
193+ if ( preg_match ( '/^#([^.,:~]|$)/ ' , $ trimmed ) ) {
194+ $ file_comments [] = $ line ;
195+ }
196+ }
197+
198+ return ! empty ( $ file_comments ) ? implode ( "\n" , $ file_comments ) . "\n" : '' ;
199+ }
200+
201+ /**
202+ * Restore file-level comments to a PO file.
203+ *
204+ * @param string $file_path Path to the PO file.
205+ * @param string $comments The file-level comments to restore.
206+ * @return bool True on success, false on failure.
207+ */
208+ private function restore_file_comments ( $ file_path , $ comments ) {
209+ $ content = file_get_contents ( $ file_path );
210+ if ( false === $ content ) {
211+ return false ;
212+ }
213+
214+ // Prepend the comments to the file content
215+ $ updated_content = $ comments . $ content ;
216+
217+ // Use atomic file operation with temporary file
218+ $ temp_file = $ file_path . '.tmp ' ;
219+ if ( false === file_put_contents ( $ temp_file , $ updated_content ) ) {
220+ return false ;
221+ }
222+
223+ // Rename is atomic on most filesystems
224+ if ( ! rename ( $ temp_file , $ file_path ) ) {
225+ // Clean up temp file on failure
226+ unlink ( $ temp_file );
227+ return false ;
228+ }
229+
230+ return true ;
231+ }
232+
129233 /**
130234 * Check if two Translations objects differ.
131235 *
@@ -216,6 +320,14 @@ private function reorder_translations( Translations $po_translations, Translatio
216320 }
217321 }
218322
323+ // Add any remaining translations from PO that aren't in POT (e.g., obsolete/disabled translations).
324+ foreach ( $ po_translations as $ po_entry ) {
325+ // Check if this entry is already in the ordered set.
326+ if ( ! $ ordered ->find ( $ po_entry ) ) {
327+ $ ordered [] = $ po_entry ->getClone ();
328+ }
329+ }
330+
219331 return $ ordered ;
220332 }
221333}
0 commit comments