*/ public static function report(string $lang): array { global $wpdb; $variants = []; foreach ((array) $wpdb->get_results( $wpdb->prepare( 'SELECT template_id, source_hash, baked_html FROM %i WHERE lang = %s', Storage::i18n_table(), $lang ), ARRAY_A ) as $v) { $variants[(int) $v['template_id']] = $v; } $out = []; foreach (Storage::published() as $tpl) { $scene = json_decode((string) $tpl['scene'], true); if (!is_array($scene)) { continue; } $strings = SceneStrings::extract($scene); /* * A template with nothing to translate is reported too, with a count of zero. Leaving it * out would look like it had been missed, and "why is this one not in the list" is a question * nobody should have to answer twice. */ $variant = $variants[(int) $tpl['id']] ?? null; $baked = trim((string) ($variant['baked_html'] ?? '')); if (!$variant || $baked === '') { $state = self::MISSING; } elseif ((string) $variant['source_hash'] !== SceneStrings::hash($scene)) { $state = self::STALE; } else { $state = self::OK; } $out[] = [ 'id' => (int) $tpl['id'], 'slug' => (string) $tpl['slug'], 'title' => (string) $tpl['title'], 'kind' => (string) $tpl['kind'], 'state' => $strings ? $state : self::OK, 'strings' => count($strings), ]; } return $out; } /** * Translate a template's strings and return the scene to bake. * * @param int $template_id Template id. * @param string $lang Target language code. * @return array{scene:array,source_hash:string,translated:int,total:int}|\WP_Error */ public static function prepare(int $template_id, string $lang) { $tpl = Storage::get($template_id); if (!$tpl) { return new \WP_Error('aumframe_no_template', __('Template not found.', 'aumframe'), ['status' => 404]); } $scene = json_decode((string) $tpl['scene'], true); if (!is_array($scene) || empty($scene['root'])) { return new \WP_Error('aumframe_bad_scene', __('That template has no usable scene.', 'aumframe'), ['status' => 400]); } $strings = SceneStrings::extract($scene); $hash = SceneStrings::hash($scene); if (!$strings) { // Nothing to translate is a success, not an error: the variant is simply the source. return ['scene' => $scene, 'source_hash' => $hash, 'source_strings' => [], 'translated' => 0, 'total' => 0]; } /* * Refuse when nothing is listening, rather than translating nothing and calling it done. * * Without this the run completes: no listener means an empty batch back, the scene is applied * unchanged, the browser bakes it — real HTML, so `store()` accepts it — and the row goes * `Up to date` while the stored variant is the source language word for word. That is the * exact lie `store()`'s empty-bake guard exists to prevent, arriving by the other door. * * This became reachable on 2026-10-06. Before that the languages themselves only ever came from * AumLang (or our theme), and AumLang answers both filters — so "languages exist" implied * "a translator exists". `wpml-bridge.php` broke that implication on purpose: WPML and Polylang * supply the language list and nothing else, because translating the words typed into a design * is not something either of them is asked to do. */ if (!self::has_translator()) { return new \WP_Error( 'aumframe_no_translator', __('Nothing here can translate a layout. Your multilingual plugin says which languages this site has, and Aumframe knows which words are in the design — but turning those words into another language is a third job, and no installed plugin does it. AumLang does; WPML and Polylang translate your posts and pages, not the text typed into a layout.', 'aumframe'), ['status' => 501] ); } /* * Reuse what is already translated and still current, so re-running after editing one section * costs one section rather than the whole template — and so a failure halfway leaves progress. */ [$done, $from] = self::existing($template_id, $lang); /* * Reuse needs both the path and the English it was made from. Keyed on the path alone a * node keeps its old translation after the author rewrites its text, because ids survive an * edit — so the edited sentence would never be retranslated and nothing would say so. */ $todo = []; $keep = []; foreach ($strings as $path => $source) { if (isset($done[$path]) && ($from[$path] ?? '') === sha1($source)) { $keep[$path] = $done[$path]; } else { $todo[$path] = $source; } } $fresh = []; if ($todo) { /** * Filters a batch of layout strings into their translations. * * Return a map with the same keys. Anything missing or unchanged is left in the source * language, which is a working page — never blank it. * * @param array $translations Empty to begin with. * @param array $strings path => source text. * @param string $lang Target language code. */ $fresh = (array) apply_filters('aumframe/translate_strings', [], $todo, $lang); } $applied = []; foreach ($strings as $path => $source) { $value = $fresh[$path] ?? $keep[$path] ?? ''; if (is_string($value) && $value !== '') { $applied[$path] = $value; } } /* * Asked for translations and got none back: that is a failure, not a finished job. * * This is the guard that actually keeps the report honest, and it judges the result * rather than who was listening — because every way this can go wrong looks identical from * here: nobody hooked the filter, a bridge is hooked but its plugin is not installed (our * own theme's bridge does exactly that), the service was unreachable, the key expired, the * quota ran out. None of them raise anything. * * Without this the run completes: the scene is applied unchanged, the browser bakes real * HTML, `store()` accepts it (it only refuses an *empty* bake) and the row reads * `Up to date` over a page that is word for word the source language. * * Zero only. A partial result is a normal, supported state — "anything missing is * left in the source language, which is a working page" — and refusing those would throw * away work that did arrive. */ if (!$applied) { return new \WP_Error( 'aumframe_translation_empty', sprintf( /* translators: %d: number of pieces of text that were sent for translation. */ __('Nothing came back. %d pieces of text were sent for translation and none were returned, so there is nothing to save — the layout would have been stored unchanged and then reported as up to date. Check that the plugin doing the translating is installed, active and configured.', 'aumframe'), count($todo) ), ['status' => 502] ); } // What each stored translation was made from, so the next run knows which have gone stale. $from = []; foreach (array_keys($applied) as $path) { $from[$path] = sha1($strings[$path]); } return [ 'scene' => SceneStrings::apply($scene, $applied), 'source_hash' => $hash, 'source_strings' => $from, 'translated' => count($applied), 'total' => count($strings), ]; } /** * What is already translated, and what English each piece was translated from. * * @param int $template_id Template id. * @param string $lang Language code. * @return array{0:array,1:array} [path => translation, path => sha1(source)] */ private static function existing(int $template_id, string $lang): array { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT scene, source_strings FROM %i WHERE template_id = %d AND lang = %s LIMIT 1', Storage::i18n_table(), $template_id, $lang ), ARRAY_A ); if (!$row) { return [[], []]; } $scene = json_decode((string) ($row['scene'] ?? ''), true); $from = json_decode((string) ($row['source_strings'] ?? ''), true); return [ is_array($scene) ? SceneStrings::extract($scene) : [], is_array($from) ? $from : [], ]; } /** * Store a baked variant. * * @param int $template_id Template id. * @param string $lang Language code. * @param string $scene_json Translated scene, as JSON. * @param string $html Baked html from the browser. * @param string $css Baked css from the browser. * @param string $source_hash The source fingerprint this was made from. * @param array $source_strings path => sha1 of the source text each translation was made from. * @return bool */ public static function store(int $template_id, string $lang, string $scene_json, string $html, string $css, string $source_hash, array $source_strings = []): bool { global $wpdb; /* * Refuse an empty bake rather than storing it. `Storage::localize()` ignores such a row, so an * empty one is merely useless — but it would also read as "translated" in the report, and the * buyer would be left looking at an English page that the admin says is done. */ if (trim($html) === '') { return false; } return false !== $wpdb->replace( Storage::i18n_table(), [ 'template_id' => $template_id, 'lang' => $lang, 'scene' => $scene_json, 'baked_html' => $html, 'baked_css' => $css, 'source_hash' => $source_hash, 'source_strings' => (string) wp_json_encode($source_strings), 'updated_at' => current_time('mysql'), ], ['%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s'] ); } }