View Issue Details

IDProjectCategoryView StatusLast Update
4694Composrcore_comcode_pagespublic2024-01-08 21:26
ReporterChris Graham Assigned ToGuest  
PrioritynormalSeverityfeature 
Status newResolutionopen 
Summary4694: Phrase-based translation
DescriptionImplement an alternative to the page-by-page translation: allowing pages to be translated by individual phrase. Have a UI for translating the phrases. Phrases would be auto-detected.

This has big advantages, mainly that if the original (e.g. English) copy of a page changes, the changes reflect for all languages (as untranslated) rather than everything diverging and needing a lot of diffing by translators to get back in sync.
Additional InformationI have this implemented for a client, I just need to merge the patch. EDIT: The patch is probably now outdated.
TagsHas Patch, Roadmap: Over the horizon, Type: Internationalisation
Attach Tags
Attached Files
translation_system.diff (59,679 bytes)   
diff --git a/cms/pages/modules/cms_comcode_pages.php b/cms/pages/modules/cms_comcode_pages.php
index f2ba8fa9..e6f84fd6 100644
--- a/cms/pages/modules/cms_comcode_pages.php
+++ b/cms/pages/modules/cms_comcode_pages.php
@@ -141,12 +141,12 @@ class Module_cms_comcode_pages
             $zone = $page_link_parts[0];
             $file = $page_link_parts[1];
 
+            $lang = get_param_string('lang', get_param_string('keep_lang', get_site_default_lang()));
+
             if ($page_link != '') {
                 breadcrumb_set_self(do_lang_tempcode('EDIT'));
             }
-            breadcrumb_set_parents(array(array('_SELF:_SELF:browse:lang=' . get_param_string('lang', ''), do_lang_tempcode('COMCODE_PAGES'))));
-
-            $lang = get_param_string('lang', get_site_default_lang());
+            breadcrumb_set_parents(array(array('_SELF:_SELF:browse:lang=' . $lang, do_lang_tempcode('COMCODE_PAGES'))));
 
             if ($file == '') {
                 $this->title = get_screen_title('COMCODE_PAGE_ADD', true, array(escape_html($zone), escape_html($file)));
@@ -237,7 +237,7 @@ class Module_cms_comcode_pages
      * @param  LANGUAGE_NAME $lang The language we are searching for pages of
      * @param  ?array $zone_filter List of zones to limit to (null: none)
      * @param  boolean $check_permissions Whether to check edit permissions
-     * @return array The map (page link => map [path & row])
+     * @return array The map (page link => pair [path & row])
      */
     public function get_comcode_files_list_disk_search($lang, $zone_filter, $check_permissions = true)
     {
@@ -677,10 +677,22 @@ class Module_cms_comcode_pages
                     foreach (['comcode_custom', 'comcode'] as $page_type) {
                         foreach ((($row['zone'] == '') && (get_option('collapse_user_zones') == '1')) ? ['', 'site'] : [$row['zone']] as $_zone) {
                             foreach (array_unique([get_custom_file_base(), get_file_base()]) as $file_base) {
-                                $test_path = $file_base . (($_zone == '') ? '' : ('/' . $_zone)) . '/pages/' . $page_type . '/' . $_lang . '/' . $row['page'] . '.txt';
-                                if (is_file($test_path)) {
-                                    $translations_found[$_lang] = filemtime($test_path);
-                                    continue 4;
+                                if ((get_option('page_translation_method') == 'automatic') || (get_option('page_translation_method') == 'phrases')) {
+                                    $test_path = $file_base . (($_zone == '') ? '' : ('/' . $_zone)) . '/pages/' . $page_type . '/' . get_site_default_lang() . '/' . $row['page'] . '.txt.json';
+                                    if (is_file($test_path)) {
+                                        $result = json_decode(cms_file_get_contents_safe($test_path), true);
+                                        if (isset($result[$_lang])) {
+                                            $translations_found[$_lang] = true;
+                                            continue 4;
+                                        }
+                                    }
+                                }
+                                if ((get_option('page_translation_method') == 'automatic') || (get_option('page_translation_method') == 'full_pages')) {
+                                    $test_path = $file_base . (($_zone == '') ? '' : ('/' . $_zone)) . '/pages/' . $page_type . '/' . $_lang . '/' . $row['page'] . '.txt';
+                                    if (is_file($test_path)) {
+                                        $translations_found[$_lang] = filemtime($test_path);
+                                        continue 4;
+                                    }
                                 }
                             }
                         }
@@ -747,7 +759,11 @@ class Module_cms_comcode_pages
                 foreach ($langs as $_lang => $language_full_name) {
                     if (array_key_exists($_lang, $translations_found)) {
                         $translation_time = $translations_found[$_lang];
-                        $translation_label = do_lang_tempcode('_AGO', escape_html(display_time_period(time() - $translation_time)));
+                        if ($translation_time === true) {
+                            $translation_label = do_lang_tempcode('PHRASES');
+                        } else {
+                            $translation_label = do_lang_tempcode('_AGO', escape_html(display_time_period(time() - $translation_time)));
+                        }
                         $translation_tooltip = get_timezoned_date($translation_time);
                     } else {
                         $translation_label = do_lang_tempcode('ADD');
@@ -870,11 +886,24 @@ class Module_cms_comcode_pages
             warn_exit(do_lang_tempcode('BAD_CODENAME'));
         }
 
-        $lang = choose_language(get_screen_title(($file == '') ? 'COMCODE_PAGE_ADD' : 'COMCODE_PAGE_EDIT'), true);
+        $lang = choose_language($this->title, true);
         if (is_object($lang)) {
             return $lang;
         }
 
+        list($file_base, $file_path) = find_comcode_page($lang, $file, $zone);
+
+        $page_translation_method = get_option('page_translation_method');
+        if (($lang != get_site_default_lang()) && (($page_translation_method == 'automatic') || ($page_translation_method == 'phrases'))) {
+            list($file_base_default_lang, $file_path_default_lang) = find_comcode_page(get_site_default_lang(), $file, $zone);
+            $dynamic_trans_path = $file_base_default_lang . '/' . $file_path_default_lang . '.json';
+            if ((is_file($dynamic_trans_path)) || ($page_translation_method == 'phrases') || (get_param_string('page_translation_method', null) === 'phrases')) {
+                return $this->_edit_dynamic_trans();
+            } elseif ((strpos($file_path, '/' . $lang . '/') === false) && ($page_translation_method == 'automatic') && (get_param_string('page_translation_method', null) !== 'full_pages')) {
+                return $this->_choose_page_translation_method();
+            }
+        }
+
         if ((get_param_string('page_template', null) === null) && (get_param_integer('may_choose_template', 0) == 1) && (get_value('page_template_always_ask', '0', true) == '1')) {
             $template_list = create_selection_list_page_templates();
 
@@ -908,8 +937,6 @@ class Module_cms_comcode_pages
             }
         }
 
-        list($file_base, $file_path) = find_comcode_page($lang, $file, $zone);
-
         // Check no redirects in our way
         if (addon_installed('redirects_editor')) {
             $test = $GLOBALS['SITE_DB']->query_select_value_if_there('redirects', 'r_to_zone', array('r_from_page' => $file, 'r_from_zone' => $zone));
@@ -1148,6 +1175,248 @@ class Module_cms_comcode_pages
         ));
     }
 
+    protected function _choose_page_translation_method()
+    {
+        require_code('form_templates');
+
+        require_lang('config');
+
+        $text = do_lang_tempcode('CONFIG_OPTION_page_translation_method');
+
+        $fields = new Tempcode();
+
+        $radios = new Tempcode();
+        $radios->attach(form_input_radio_entry('page_translation_method', 'full_pages', false, do_lang_tempcode('CONFIG_OPTION_page_translation_method_VALUE_full_pages')));
+        $radios->attach(form_input_radio_entry('page_translation_method', 'phrases', false, do_lang_tempcode('CONFIG_OPTION_page_translation_method_VALUE_phrases')));
+        $fields->attach(form_input_radio(do_lang_tempcode('PAGE_TRANSLATION_METHOD'), '', 'page_translation_method', $radios, true));
+
+        $post_url = get_self_url(false, false, null, false, true);
+
+        return do_template('FORM_SCREEN', array(
+            'GET' => true,
+            'SKIP_WEBSTANDARDS' => true,
+            'HIDDEN' => '',
+            'SUBMIT_ICON' => 'buttons__proceed',
+            'SUBMIT_NAME' => do_lang_tempcode('PROCEED'),
+            'TITLE' => $this->title,
+            'FIELDS' => $fields,
+            'URL' => $post_url,
+            'TEXT' => $text,
+            'JAVASCRIPT' => '',
+        ));
+    }
+
+    /**
+     * UI/actualiser for doing dynamic translation for a Comcode page.
+     *
+     * @return Tempcode The UI
+     */
+    protected function _edit_dynamic_trans()
+    {
+        require_code('lang_dynamic_trans');
+        require_code('site2');
+        require_code('users_active_actions');
+
+        $zone = $this->zone;
+        $codename = $this->file;
+
+        $lang = choose_language($this->title, true);
+
+        $javascript = '';
+
+        $text = do_lang_tempcode(
+            'TRANSLATE_PAGE_PHRASES',
+            escape_html(lookup_language_full_name(get_site_default_lang())),
+            escape_html(lookup_language_full_name($lang)),
+            array(
+                escape_html($lang),
+                escape_html($zone),
+                escape_html($codename),
+                build_url(array('page' => $codename, 'keep_lang' => get_site_default_lang()), $zone),
+                build_url(array('page' => $codename, 'keep_lang' => $lang), $zone),
+            )
+        );
+
+        // Load
+        list($file_base_default_lang, $string_default_lang) = find_comcode_page(get_site_default_lang(), $codename, $zone);
+        $dynamic_trans_path = $file_base_default_lang . '/' . $string_default_lang . '.json';
+        $dynamic_trans = is_file($dynamic_trans_path) ? json_decode(cms_file_get_contents_safe($dynamic_trans_path), true) : array();
+
+        // Load from other pages too
+        $dynamic_trans_other = array();
+        $pages_on_disk = $this->get_comcode_files_list_disk_search(get_site_default_lang(), null, false);
+        foreach ($pages_on_disk as $page_on_disk) {
+            $test_path = get_custom_file_base() . '/' . $page_on_disk[0] . '.json';
+            if (!is_file($test_path)) {
+                $test_path = get_file_base() . '/' . $page_on_disk[0] . '.json';
+            }
+            if (is_file($test_path)) {
+                $_result = json_decode(cms_file_get_contents_safe($test_path), true);
+                if (isset($_result[$lang])) {
+                    $dynamic_trans_other += $_result[$lang];
+                }
+            }
+        }
+
+        // Get full context of original language...
+
+        $_comcode_page_row = $GLOBALS['SITE_DB']->query_select('comcode_pages', array('*'), array('the_zone' => $zone, 'the_page' => $codename), '', 1);
+        $comcode_page_row = array_key_exists(0, $_comcode_page_row) ? $_comcode_page_row[0] : null;
+
+        if ($comcode_page_row === null) {
+            $page_submitter = get_first_admin_user();
+        } else {
+            $page_submitter = $comcode_page_row['p_submitter'];
+        }
+
+        list($file_base, $file_path) = find_comcode_page($lang, $codename, $zone);
+        list($html, $comcode) = __load_comcode_page_from_disk($file_path, $zone, $codename, $file_base, $page_submitter, array($this, '_strip_non_translatable'));
+
+        $translator = new HTMLDynamicTranslator();
+        $result = $translator->extract_html_text_phrases($html->evaluate(), true);
+
+        // Save
+        if (post_param_integer('saving_dynamic_trans', 0) == 1) {
+            if (post_param_integer('delete_unused', 0) == 1) {
+                unset($dynamic_trans[$lang]);
+            }
+
+            foreach ($result['phrases'] as $string_key => $phrase) {
+                $string = post_param_string('phrase_' . $string_key, null);
+
+                if ($string !== null) {
+                    $string = trim($string);
+
+                    if ($string != '') {
+                        if (!$phrase['string_is_html']) {
+                            $string = escape_html($string);
+                        }
+
+                        $changed = ($phrase['string'] != $string);
+                        if ($changed) {
+                            $dynamic_trans[$lang][$phrase['string']] = $string;
+                        } else {
+                            unset($dynamic_trans[$lang][$phrase['string']]);
+                        }
+                    }
+                }
+            }
+
+            if (empty($dynamic_trans)) {
+                @unlink($dynamic_trans_path) or intelligent_write_error($dynamic_trans_path);
+            } else {
+                cms_file_put_contents_safe($dynamic_trans_path, json_encode($dynamic_trans, JSON_PRETTY_PRINT), FILE_WRITE_FIX_PERMISSIONS | FILE_WRITE_SYNC_FILE);
+            }
+
+            require_code('zones3');
+            empty_cache_for_comcode_page($zone, $codename);
+
+            attach_message(do_lang_tempcode('SUCCESS_SAVE'), 'inform');
+        }
+
+        // UI...
+
+        $fields = new Tempcode();
+
+        $i = 0;
+        $has_auto_copy = false;
+        foreach ($result['phrases'] as $string_key => $phrase) {
+            $name = 'phrase_' . $string_key;
+            $pretty_name = do_lang_tempcode($phrase['string_is_html'] ? 'PHRASE_X_HTML' : 'PHRASE_X_PLAIN', escape_html(integer_format($i + 1)));
+            $description = protect_from_escaping('“' . ($phrase['string_is_html'] ? $phrase['string'] : escape_html($phrase['string'])) . '”.');
+            if (isset($dynamic_trans[$lang][$phrase['string']])) {
+                $default = $dynamic_trans[$lang][$phrase['string']];
+                if (!$phrase['string_is_html']) {
+                    $default = html_entity_decode($default, ENT_QUOTES, get_charset());
+                }
+            } elseif (isset($dynamic_trans_other[$phrase['string']])) {
+                $default = $dynamic_trans_other[$phrase['string']];
+                if (!$phrase['string_is_html']) {
+                    $default = html_entity_decode($default, ENT_QUOTES, get_charset());
+                }
+
+                $pretty_name = do_lang_tempcode('TRANSLATION_COPIED_FROM', $pretty_name);
+
+                $has_auto_copy = true;
+            } else {
+                $default = $phrase['string'];
+            }
+
+            $field = form_input_text($pretty_name, $description, $name, $default, false, null, true, null, substr_count($phrase['string'], "\n") + 1);
+
+            $fields->attach($field);
+
+            $javascript .= '
+                document.getElementById(\'' . $name . '\').onmousedown=function() { this.just_clicked=true; };
+                document.getElementById(\'' . $name . '\').onfocus=function() { if (typeof this.just_clicked==\'undefined\') this.select(); delete this.just_clicked; };
+                document.getElementById(\'' . $name . '\').onkeyup=function(event) { if (event.keyCode==45) { this.value=this.value.substring(0,this.selectionStart)+\'<a href="{1}">\'+this.value.substring(this.selectionStart,this.selectionEnd)+\'</a>\'+this.value.substring(this.selectionEnd,this.value.length); } };
+            ';
+
+            $i++;
+        }
+
+        $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', array('TITLE' => do_lang_tempcode('ACTIONS'))));
+        $fields->attach(form_input_tick(do_lang_tempcode('DELETE_UNUSED_PHRASES'), do_lang_tempcode('DESCRIPTION_DELETE_UNUSED_PHRASES'), 'delete_unused', false));
+
+        $post_url = get_self_url(false, false, array('page_translation_method' => 'phrases', 'lang' => $lang));
+
+        $hidden = new Tempcode();
+        $hidden->attach(form_input_hidden('saving_dynamic_trans', '1'));
+
+        if ($has_auto_copy) {
+            $text->attach(paragraph(do_lang_tempcode('TRANSLATIONS_COPIED_FROM')));
+        }
+
+        // Indicators for preview (makes it simpler)
+        $hidden->attach(form_input_hidden('zone', $zone));
+        $hidden->attach(form_input_hidden('file', $codename));
+        $hidden->attach(form_input_hidden('lang', $lang));
+
+        return do_template('FORM_SCREEN', array(
+            'SKIP_WEBSTANDARDS' => true,
+            'HIDDEN' => $hidden,
+            'SUBMIT_ICON' => 'buttons__save',
+            'SUBMIT_NAME' => do_lang_tempcode('SAVE'),
+            'TITLE' => $this->title,
+            'FIELDS' => $fields,
+            'URL' => $post_url,
+            'TEXT' => $text,
+            'JAVASCRIPT' => $javascript,
+            'PREVIEW' => true,
+        ));
+    }
+
+    /**
+     * Strip Comcode that should not be evaluated and put through the translation system.
+     *
+     * @param  string $comcode In
+     * @return string Out
+     */
+    public function _strip_non_translatable($comcode)
+    {
+        $comcode = preg_replace('#\[block([\s=].*)?\]\w+\[/block\]#Ui', '', $comcode);
+        $comcode = preg_replace('#{\$BLOCK,[^{}*]}#Ui', '', $comcode);
+
+        do {
+            $block_tempcode_pos = strpos($comcode, '{$BLOCK');
+            if ($block_tempcode_pos !== false) {
+                $len = strlen($comcode);
+                $balance = 1;
+                for ($i = $block_tempcode_pos + 1; ($i < $len) && ($balance != 0); $i++) {
+                    $c = $comcode[$i];
+                    if ($c == '{') {
+                        $balance++;
+                    } elseif ($c == '}') {
+                        $balance--;
+                    }
+                }
+                $comcode = substr($comcode, 0, $block_tempcode_pos) . substr($comcode, $i);
+            }
+        } while ($block_tempcode_pos !== false);
+
+        return $comcode;
+    }
+
     /**
      * Whether the Comcode page editor has integrated menu editing.
      *
diff --git a/lang/EN/config.ini b/lang/EN/config.ini
index 68aa8068..0c7e8597 100644
--- a/lang/EN/config.ini
+++ b/lang/EN/config.ini
@@ -533,3 +533,9 @@ CONFIG_OPTION_wysiwyg_font_units_VALUE_em=Element-relative, <kbd>em</kbd> (dynam
 
 IPSTACK_API_KEY=ipstack API key
 CONFIG_OPTION_ipstack_api_key=ipstack is used for gathering advanced IP address information to automatically attach to contact forms. A <a href="https://ipstack.com/product" target="_blank" title="Register for ipstack (this link will open in a new window)">free key</a> is available.
+
+PAGE_TRANSLATION_METHOD=Page translation method
+CONFIG_OPTION_page_translation_method=Select which page translation method to use.
+CONFIG_OPTION_page_translation_method_VALUE_automatic=(Selected on a page-by-page basis)
+CONFIG_OPTION_page_translation_method_VALUE_full_pages=Translate whole pages (monolithic)
+CONFIG_OPTION_page_translation_method_VALUE_phrases=Translate individual phrases (granular)
diff --git a/lang/EN/zones.ini b/lang/EN/zones.ini
index f5d87510..712d0a10 100644
--- a/lang/EN/zones.ini
+++ b/lang/EN/zones.ini
@@ -111,3 +111,12 @@ DESCRIPTION_INCLUDE_ON_SITEMAP=Whether this page will be shown on the sitemap, s
 SYNC_REVISIONS_WITH_GIT=Sync revisions with Git
 SYNC_REVISIONS_WITH_GIT_CONFIRM=When manually editing page files and using a revision control system, the revision information within the software will be incomplete. This tool will go over the Git revision history to create page revisions for any commits within Git to your pages, as well as bump forward edit dates in the database as required. For this tool to work the <kbd>git</kbd> executable must be in the path and callable from PHP.
 SYNC_REVISIONS_WITH_GIT_MESSAGE=Synched {1} {1|revision|revisions} from Git.
+
+TRANSLATE_PAGE_PHRASES=This screen lets you translate all the phrases in the default <a href="{6}" target="_blank" title="{1} version (this link will open in a new window)">{1} version</a> of <kbd>{4}:{5}</kbd> to the <a href="{7}" target="_blank" title="{2} version (this link will open in a new window)" />{2} version</a>.<!--<br />You can insert a parameterised hyperlink around a text selection by pressing the insert key, which will work so long as the original text also has such a link.--><br />If you need to reset any phrase back to {1}, you can just leave it blank.<br />Note that any referenced media files in the File/Media library can be automatically substituted via a file naming convention; e.g. if you have <kbd>example.jpg</kbd> then <kbd>example_{3}.jpg</kbd> will be used if it exists.
+PHRASE_X_HTML=Phrase {1} (HTML)
+PHRASE_X_PLAIN=Phrase {1} (Plain)
+PHRASES=Phrases
+DELETE_UNUSED_PHRASES=Delete unused phrases
+DESCRIPTION_DELETE_UNUSED_PHRASES=Delete phrases that can not be seen in a current scan of the page. These may be old translated phrases no longer present in the page, or phrases that that only come up in certain conditions. Use this option with a lot of care.
+TRANSLATION_COPIED_FROM={1} <span class="red_alert">&dagger;</span>
+TRANSLATIONS_COPIED_FROM=<span class="red_alert">&dagger;</span> Translations were copied from other pages automatically.
diff --git a/sources/hooks/systems/config/page_translation_method.php b/sources/hooks/systems/config/page_translation_method.php
new file mode 100644
index 00000000..d0f351aa
--- /dev/null
+++ b/sources/hooks/systems/config/page_translation_method.php
@@ -0,0 +1,55 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_language_editing
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_config_page_translation_method
+{
+    /**
+     * Gets the details relating to the config option.
+     *
+     * @return ?array The details (null: disabled)
+     */
+    public function get_details()
+    {
+        return array(
+            'human_name' => 'PAGE_TRANSLATION_METHOD',
+            'type' => 'list',
+            'category' => 'SITE',
+            'group' => 'INTERNATIONALISATION',
+            'explanation' => 'CONFIG_OPTION_page_translation_method',
+            'shared_hosting_restricted' => '0',
+            'list_options' => 'automatic|full_pages|phrases',
+
+            'addon' => 'core_language_editing',
+        );
+    }
+
+    /**
+     * Gets the default value for the config option.
+     *
+     * @return ?string The default value (null: option is disabled)
+     */
+    public function get_default()
+    {
+        return 'automatic';
+    }
+}
diff --git a/sources/hooks/systems/preview/comcode_page.php b/sources/hooks/systems/preview/comcode_page.php
index f7222da0..e6ebe7fc 100644
--- a/sources/hooks/systems/preview/comcode_page.php
+++ b/sources/hooks/systems/preview/comcode_page.php
@@ -31,6 +31,11 @@ class Hook_preview_comcode_page
     public function applies()
     {
         $applies = (get_page_name() == 'cms_comcode_pages');
+
+        if (post_param_integer('saving_dynamic_trans', 0) == 1) {
+            return array($applies, null, false);
+        }
+
         return array($applies, 'comcode_page', false, array('post'));
     }
 
@@ -44,6 +49,47 @@ class Hook_preview_comcode_page
         $codename = post_param_string('file');
         $zone = post_param_string('zone');
 
+        if (post_param_integer('saving_dynamic_trans', 0) == 1) {
+            require_code('site2');
+            require_code('lang_dynamic_trans');
+
+            $lang = post_param_string('lang');
+
+            $_comcode_page_row = $GLOBALS['SITE_DB']->query_select('comcode_pages', array('*'), array('the_zone' => $zone, 'the_page' => $codename), '', 1);
+            $comcode_page_row = array_key_exists(0, $_comcode_page_row) ? $_comcode_page_row[0] : null;
+
+            if ($comcode_page_row === null) {
+                $page_submitter = get_first_admin_user();
+            } else {
+                $page_submitter = $comcode_page_row['p_submitter'];
+            }
+
+            list($file_base, $file_path) = find_comcode_page($lang, $codename, $zone);
+            list($html) = __load_comcode_page_from_disk($file_path, $zone, $codename, $file_base, $page_submitter);
+
+            $dynamic_trans = array();
+
+            $translator = new HTMLDynamicTranslator();
+            $result = $translator->extract_html_text_phrases($html->evaluate(), true);
+            foreach ($result['phrases'] as $string_key => $phrase) {
+                $string = post_param_string('phrase_' . $string_key, null);
+
+                if ($string !== null) {
+                    $string = trim($string);
+
+                    if ($string != '') {
+                        if (!$phrase['string_is_html']) {
+                            $string = escape_html($string);
+                        }
+
+                        $dynamic_trans[$lang][$phrase['string']] = $string;
+                    }
+                }
+            }
+
+            return array(lang_dynamic_trans($dynamic_trans, $html, $lang), null);
+        }
+
         $original_comcode = post_param_string('post');
 
         $posting_ref_id = post_param_integer('posting_ref_id', mt_rand(0, mt_getrandmax()));
diff --git a/sources/lang3.php b/sources/lang3.php
index 14453af8..ed8eb775 100644
--- a/sources/lang3.php
+++ b/sources/lang3.php
@@ -33,8 +33,7 @@ function _choose_language($title, $tip = false, $allow_all_selection = false)
         return user_lang();
     }
 
-    $lang = get_param_string('lang', /*get_param_string('keep_lang', null)*/
-        null);
+    $lang = get_param_string('lang', /*get_param_string('keep_lang', null)*/get_param_string('keep_lang', null));
     if ($lang !== null) {
         return filter_naughty($lang);
     }
diff --git a/sources/lang_dynamic_trans.php b/sources/lang_dynamic_trans.php
new file mode 100644
index 00000000..9ccff9a1
--- /dev/null
+++ b/sources/lang_dynamic_trans.php
@@ -0,0 +1,555 @@
+<?php
+
+/**
+ * Dynamically translate a Comcode page based on existing phrase translations.
+ *
+ * @param  array $dynamic_trans Details of what translation to do
+ * @param  Tempcode $html Untranslated version of the page
+ * @param  LANGUAGE_NAME $lang Language to translate into
+ * @return Tempcode Translated version of the page
+ */
+function lang_dynamic_trans($dynamic_trans, $html, $lang)
+{
+    $html_evaluated = $html->evaluate($lang);
+
+    $translator = new HTMLDynamicTranslator();
+    $result = $translator->extract_html_text_phrases($html->evaluate());
+
+    if ((isset($dynamic_trans[$lang])) && (!empty($result['phrases']))) {
+        $_html_evaluated = '';
+
+        $last_end = 0;
+
+        foreach ($result['phrases'] as $phrase_details) {
+            $_html_evaluated .= substr($html_evaluated, $last_end, $phrase_details['start'] - $last_end);
+
+            $portion = substr($html_evaluated, $phrase_details['start'], $phrase_details['end'] - $phrase_details['start']);
+
+            $string = $phrase_details['string'];
+
+            $string_html = $phrase_details['string_html'];
+            if (isset($dynamic_trans[$lang][$string])) {
+                $string_html_regexp = '#^' . preg_quote($string_html, '#') . '$#';
+
+                $translated = $dynamic_trans[$lang][$string];
+                foreach ($phrase_details['params'] as $i => $param) {
+                    $translated = str_replace('{' . strval($i + 1) . '}', $param, $translated);
+                    $string_html_regexp = str_replace('\{' . strval($i + 1) . '\}', '.*', $string_html_regexp);
+                }
+
+                $_html_evaluated .= preg_replace($string_html_regexp, $translated, $portion);
+            } else {
+                $_html_evaluated .= $portion;
+            }
+
+            $last_end = $phrase_details['end'];
+        }
+
+        $_html_evaluated .= substr($html_evaluated, $last_end);
+
+        $html_evaluated = $_html_evaluated;
+    }
+
+    $stub = get_custom_file_base() . '/uploads/filedump/';
+    foreach ($result['urls_to_substitute'] as $url) {
+        $path = convert_url_to_path($url);
+        if (($path !== null) && (substr($path, 0, strlen($stub)) == $stub)) {
+            $ext = get_file_extension($path);
+            $path = substr($path, 0, strlen($path) - 1 - strlen($ext)) . '_' . $lang . '.' . $ext;
+            if (is_file($path)) {
+                $html_evaluated = str_replace($url, substr($url, 0, strlen($url) - 1 - strlen($ext)) . '_' . $lang . '.' . $ext, $html_evaluated);
+            }
+        }
+    }
+
+    $matches = array();
+    if (preg_match('#<h1[^<>]*>(.*)</h1>#Ui', $html_evaluated, $matches) != 0) {
+        $title_to_use = protect_from_escaping($matches[1]);
+        get_screen_title($title_to_use, false); // Little hack - this will force DISPLAYED_TITLE to get set.
+    }
+
+    return make_string_tempcode($html_evaluated);
+}
+
+/**
+ * Class to parse some HTML for finding translatable phrases.
+ */
+class HTMLDynamicTranslator
+{
+    const PARSE_NEUTRAL = 1;
+    const PARSE_IN_INLINE_TAG = 2;
+    const PARSE_IN_INLINE_ANCHOR_TAG = 3;
+    const PARSE_IN_MEDIA_TAG = 4;
+    const PARSE_IN_INPUT_BUTTON_TAG = 5;
+    const PARSE_IN_SCRIPT_TAG = 6;
+    const PARSE_IN_TAG = 7;
+    const PARSE_IN_ATTRIBUTE_DOUBLE = 8;
+    const PARSE_IN_ATTRIBUTE_SINGLE = 9;
+    const PARSE_IN_ATTRIBUTE_DOUBLE_SKIP = 10;
+    const PARSE_IN_ATTRIBUTE_SINGLE_SKIP = 11;
+    const PARSE_IN_COMMENT = 12;
+
+    protected $phrases = array();
+    protected $urls_to_substitute = array();
+
+    // Working
+    protected $current_phrase = null;
+    protected $current_phrase_start = null;
+    protected $current_phrase_params = array();
+
+    /**
+     * Parse some HTML.
+     *
+     * @param  string $html HTML to parse
+     * @param  boolean $deduplicate Whether to deduplicate the strings
+     * @return array A structure of what was parsed
+     */
+    public function extract_html_text_phrases($html, $deduplicate = false)
+    {
+        $inline_tags = array_flip(array(
+            'em',
+            'i',
+            'strong',
+            'b',
+            'u',
+            'span',
+            'abbr',
+            'acronym',
+            'kbd',
+            'samp',
+            'tt',
+            'q',
+            'var',
+            'small',
+            'big',
+            'sub',
+            'sup',
+            'time',
+            //'a', Parsed separately
+        ));
+
+        $media_tags = array_flip(array(
+            'img',
+            'embed',
+            'input',
+            'video',
+            'source',
+            'audio',
+            'track',
+        ));
+
+        $i = 0;
+        $mode = self::PARSE_NEUTRAL;
+        $previous_mode_stack = array();
+        $len = strlen($html);
+        $coming_tag = '';
+        for ($i = 0; $i < $len; $i++) {
+            $c = $html[$i];
+
+            switch ($mode) {
+                case self::PARSE_NEUTRAL:
+                    switch ($c) {
+                        case '<':
+                            if (substr($html, $i, 4) == '<!--') {
+                                $this->conclude_phrase($html, $i);
+                                array_push($previous_mode_stack, $mode);
+                                $mode = self::PARSE_IN_COMMENT;
+                                break;
+                            }
+
+                            $matches = array();
+                            preg_match('#\G/?(\w*)#', $html, $matches, 0, $i + 1);
+                            $coming_tag = strtolower($matches[1]);
+                            if (($coming_tag == 'input') && (preg_match('#\G<input[^<>]*type="(submit|button)"[^<>]*/>#Uis', $html, $matches, 0, $i) != 0)) {
+                                $this->conclude_phrase($html, $i);
+                                array_push($previous_mode_stack, $mode);
+                                $mode = self::PARSE_IN_INPUT_BUTTON_TAG;
+                                break;
+                            } elseif (isset($media_tags[$coming_tag])) {
+                                $this->conclude_phrase($html, $i);
+                                array_push($previous_mode_stack, $mode);
+                                $mode = self::PARSE_IN_MEDIA_TAG;
+                                break;
+                            } elseif ($coming_tag == 'script') {
+                                $this->conclude_phrase($html, $i);
+                                array_push($previous_mode_stack, $mode);
+                                $mode = self::PARSE_IN_SCRIPT_TAG;
+                            } elseif ($coming_tag == 'a') {
+                                if ((trim($this->current_phrase) == '') && (preg_match('#\G<a[^<>]*></a>#Uis', $html, $matches, 0, $i) != 0)) {
+                                    // Empty link...
+                                    $i += strlen($matches[0]) - 1;
+                                    break;
+                                } elseif ((trim($this->current_phrase) == '') && (preg_match('#\G(<a[^<>]*>)([^<>]*)</a>[^<]*\w#Uis', $html, $matches, 0, $i) == 0) && (preg_match('#\G(<a[^<>]*>)(.*)</a>#Uis', $html, $matches, 0, $i) != 0)) {
+                                    // Wraps some text that is not followed by other text...
+
+                                    $translator = new HTMLDynamicTranslator();
+                                    $result = $translator->extract_html_text_phrases($matches[2]);
+                                    foreach ($result['phrases'] as $phrase) {
+                                        $phrase['start'] += $i + strlen($matches[1]);
+                                        $phrase['end'] += $i + strlen($matches[1]);
+
+                                        $this->phrases[] = $phrase;
+                                    }
+                                    $this->urls_to_substitute = array_merge($this->urls_to_substitute, $result['urls_to_substitute']);
+
+                                    $i += strlen($matches[0]) - 1;
+                                    break;
+                                } else {
+                                    array_push($previous_mode_stack, $mode);
+                                    $mode = self::PARSE_IN_INLINE_ANCHOR_TAG;
+                                }
+                            } elseif (!isset($inline_tags[$coming_tag])) {
+                                $this->conclude_phrase($html, $i);
+                                array_push($previous_mode_stack, $mode);
+                                $mode = self::PARSE_IN_TAG;
+                                break;
+                            } else {
+                                array_push($previous_mode_stack, $mode);
+                                $mode = self::PARSE_IN_INLINE_TAG;
+                            }
+                            // no break
+
+                        default:
+                            $this->init_or_extend_phrase($i, $c);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_INLINE_TAG:
+                    switch ($c) {
+                        case '>':
+                            $mode = array_pop($previous_mode_stack);
+                            // no break
+
+                        default:
+                            $this->init_or_extend_phrase($i, $c);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_SCRIPT_TAG:
+                    switch ($c) {
+                        case '<':
+                            if (substr($html, $i, 9) == '</script>') {
+                                $mode = array_pop($previous_mode_stack);
+                                $i += 8;
+                            }
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_INLINE_ANCHOR_TAG:
+                    switch ($c) {
+                        case 'h':
+                            $matches = array();
+                            if (preg_match('#\Ghref="([^"]*)"#', $html, $matches, 0, $i) != 0) {
+                                $this->current_phrase_params[] = $matches[1];
+                                $parameterised_href = 'href="{' . strval(count($this->current_phrase_params)) . '}"';
+                                $this->init_or_extend_phrase($i, $parameterised_href);
+                                $i += strlen($matches[0]) - 1;
+                                break;
+                            }
+
+                            $this->init_or_extend_phrase($i, $c);
+                            break;
+
+                        case '>':
+                            $mode = array_pop($previous_mode_stack);
+                            // no break
+
+                        default:
+                            $this->init_or_extend_phrase($i, $c);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_INPUT_BUTTON_TAG:
+                    switch ($c) {
+                        case '>':
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+
+                        case '"':
+                            array_push($previous_mode_stack, $mode);
+                            $this->conclude_phrase($html, $i);
+                            if ($this->just_passed_attribute($html, $i, 'value')) {
+                                $mode = self::PARSE_IN_ATTRIBUTE_DOUBLE;
+                            } else {
+                                $mode = self::PARSE_IN_ATTRIBUTE_DOUBLE_SKIP;
+                            }
+                            break;
+
+                        case "'":
+                            array_push($previous_mode_stack, $mode);
+                            $this->conclude_phrase($html, $i);
+                            if ($this->just_passed_attribute($html, $i, 'value')) {
+                                $mode = self::PARSE_IN_ATTRIBUTE_SINGLE;
+                            } else {
+                                $mode = self::PARSE_IN_ATTRIBUTE_SINGLE_SKIP;
+                            }
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_MEDIA_TAG:
+                    switch ($c) {
+                        case 's':
+                            $matches = array();
+                            if (preg_match('#\Gsrc="([^"]*)"#', $html, $matches, 0, $i) != 0) {
+                                $this->urls_to_substitute[] = $matches[1];
+                                $i += strlen($matches[0]) - 1;
+                                break;
+                            } elseif (preg_match('#\Gsrcset="([^"]*)"#', $html, $matches, 0, $i) != 0) {
+                                $parts = explode(',', $matches[1]);
+                                foreach ($parts as $part) {
+                                    list($part_first) = explode(' ', trim($part));
+                                    $this->urls_to_substitute[] = $part_first;
+                                }
+                                $i += strlen($matches[0]) - 1;
+                                break;
+                            }
+
+                            break;
+
+                        case '>':
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+
+                        case '"':
+                            array_push($previous_mode_stack, $mode);
+                            $this->conclude_phrase($html, $i);
+                            if ($this->just_passed_text_attribute($html, $i)) {
+                                $mode = self::PARSE_IN_ATTRIBUTE_DOUBLE;
+                            } else {
+                                $mode = self::PARSE_IN_ATTRIBUTE_DOUBLE_SKIP;
+                            }
+                            break;
+
+                        case "'":
+                            array_push($previous_mode_stack, $mode);
+                            $this->conclude_phrase($html, $i);
+                            if ($this->just_passed_text_attribute($html, $i)) {
+                                $mode = self::PARSE_IN_ATTRIBUTE_SINGLE;
+                            } else {
+                                $mode = self::PARSE_IN_ATTRIBUTE_SINGLE_SKIP;
+                            }
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_TAG:
+                    switch ($c) {
+                        case '>':
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+
+                        case '"':
+                            array_push($previous_mode_stack, $mode);
+                            $this->conclude_phrase($html, $i);
+                            if ($this->just_passed_text_attribute($html, $i)) {
+                                $mode = self::PARSE_IN_ATTRIBUTE_DOUBLE;
+                            } else {
+                                $mode = self::PARSE_IN_ATTRIBUTE_DOUBLE_SKIP;
+                            }
+                            break;
+
+                        case "'":
+                            array_push($previous_mode_stack, $mode);
+                            $this->conclude_phrase($html, $i);
+                            if ($this->just_passed_text_attribute($html, $i)) {
+                                $mode = self::PARSE_IN_ATTRIBUTE_SINGLE;
+                            } else {
+                                $mode = self::PARSE_IN_ATTRIBUTE_SINGLE_SKIP;
+                            }
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_ATTRIBUTE_DOUBLE:
+                    switch ($c) {
+                        case '"':
+                            $this->conclude_phrase($html, $i);
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+
+                        default:
+                            $this->init_or_extend_phrase($i, $c);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_ATTRIBUTE_SINGLE:
+                    switch ($c) {
+                        case "'":
+                            $this->conclude_phrase($html, $i);
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+
+                        default:
+                            $this->init_or_extend_phrase($i, $c);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_ATTRIBUTE_DOUBLE_SKIP:
+                    switch ($c) {
+                        case '"':
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_ATTRIBUTE_SINGLE_SKIP:
+                    switch ($c) {
+                        case "'":
+                            $mode = array_pop($previous_mode_stack);
+                            break;
+                    }
+                    break;
+
+                case self::PARSE_IN_COMMENT:
+                    switch ($c) {
+                        case '-':
+                            if (substr($html, $i, 3) == '-->') {
+                                $mode = array_pop($previous_mode_stack);
+                                $i += 2;
+                                break;
+                            }
+                    }
+                    break;
+            }
+        }
+
+        $this->conclude_phrase($html, $i);
+
+        if ($deduplicate) {
+            $phrases = array();
+            foreach ($this->phrases as $phrase) {
+                $phrases[md5($phrase['string'])] = $phrase;
+            }
+        } else {
+            $phrases = $this->phrases;
+        }
+
+        return array(
+            'phrases' => $phrases,
+            'urls_to_substitute' => $this->urls_to_substitute,
+        );
+    }
+
+    /**
+     * See if the parser has got to the point where a text attribute of an HTML tag is about to be given its value.
+     *
+     * @param  string $html HTML
+     * @param  integer $i Parser position in HTML
+     * @return boolean Whether it has
+     */
+    protected function just_passed_text_attribute($html, $i)
+    {
+        foreach (array('alt', 'title', 'summary', 'placeholder') as $attribute) {
+            if ($this->just_passed_attribute($html, $i, $attribute)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * See if the parser has got to the point where a particular attribute of an HTML tag is about to be given its value.
+     *
+     * @param  string $html HTML
+     * @param  integer $i Parser position in HTML
+     * @param  string $attribute Attribute name
+     * @return boolean Whether it has
+     */
+    protected function just_passed_attribute($html, $i, $attribute)
+    {
+        return (strtolower(substr($html, $i - strlen($attribute) - 1, strlen($attribute))) == $attribute);
+    }
+
+    /**
+     * Initialise or extend a phrase being parsed.
+     *
+     * @param  integer $i Parser position
+     * @param  string $c Character(s)
+     */
+    protected function init_or_extend_phrase($i, $c)
+    {
+        if ($this->current_phrase === null) {
+            if (trim($c) == '') {
+                return;
+            }
+
+            $this->current_phrase = '';
+            $this->current_phrase_start = $i;
+        }
+
+        $this->current_phrase .= $c;
+    }
+
+    /**
+     * Conclude a phrase just parsed into our data structure (used internally be the parser).
+     *
+     * @param  string $html HTML being parsed
+     * @param  integer $i Parser position
+     */
+    protected function conclude_phrase($html, $i)
+    {
+        $phrase = $this->current_phrase;
+
+        $start = $this->current_phrase_start;
+        $end = $i;
+
+        // Strip leading closing tags
+        $len1 = strlen($phrase);
+        $phrase = preg_replace('#^(\s*</\w+>)+#', '', $phrase);
+        $len2 = strlen($phrase);
+        $start += $len1 - $len2;
+
+        // Strip trailing opening tags
+        $len1 = strlen($phrase);
+        $phrase = preg_replace('#(<\w+[^<>]*>\s*)+$#', '', $phrase);
+        $len2 = strlen($phrase);
+        $end -= $len1 - $len2;
+
+        // Trim from front
+        $len1 = strlen($phrase);
+        $phrase = ltrim($phrase);
+        $len2 = strlen($phrase);
+        $start += $len1 - $len2;
+
+        // Trim from end
+        $len1 = strlen($phrase);
+        $phrase = rtrim($phrase);
+        $len2 = strlen($phrase);
+        $end -= $len1 - $len2;
+
+        if (strip_tags($phrase) != '') {
+            $charset = function_exists('get_charset') ? get_charset() : 'utf-8';
+            $decoded = html_entity_decode($phrase, ENT_QUOTES, $charset);
+            if (htmlentities($decoded, ENT_QUOTES, $charset) == $phrase) {
+                $string_is_html = false;
+                $string = $decoded;
+            } else {
+                $string_is_html = true;
+                $string = $phrase;
+            }
+
+            $phrase_details = array(
+                'string' => $string,
+                'string_is_html' => $string_is_html,
+                'string_html' => $phrase,
+                'params' => $this->current_phrase_params,
+
+                'start' => $start,
+                'end' => $end,
+            );
+            $this->phrases[] = $phrase_details;
+
+            //$check = substr($html, $start, $end - $start);@var_dump($check);@var_dump($this->phrases[md5($phrase)]); For debugging
+        }
+        $this->current_phrase = null;
+        $this->current_phrase_start = null;
+        $this->current_phrase_params = array();
+    }
+}
diff --git a/sources/site.php b/sources/site.php
index 756a3939..35ac2f48 100644
--- a/sources/site.php
+++ b/sources/site.php
@@ -1872,6 +1872,31 @@ function load_comcode_page($string, $zone, $codename, $file_base = null, $being_
         list($html, $comcode_page_row, $title_to_use, $raw_comcode) = _load_comcode_page_cache_off($string, $zone, $codename, $file_base, $new_comcode_page_row, $being_included);
     }
 
+    // Dynamic translation mode (works via string substitutions)
+    //  Has to happen after Comcode page caching, as otherwise we would flatten blocks and other Tempcode
+    if (get_option('page_translation_method') == 'phrases') {
+        require_code('lang_dynamic_trans');
+        list($file_base_default_lang, $string_default_lang) = find_comcode_page(get_site_default_lang(), $codename, $zone);
+        $dynamic_trans_path = $file_base_default_lang . '/' . $string_default_lang . '.json';
+    } elseif (get_option('page_translation_method') == 'automatic') {
+        list($file_base_default_lang, $string_default_lang) = find_comcode_page(get_site_default_lang(), $codename, $zone);
+        $_dynamic_trans_path = $file_base_default_lang . '/' . $string_default_lang . '.json';
+        if (is_file($_dynamic_trans_path)) {
+            $dynamic_trans_path = $_dynamic_trans_path;
+        }
+    }
+    if ($dynamic_trans_path !== null) {
+        if (file_exists($dynamic_trans_path)) {
+            $dynamic_trans = json_decode(cms_file_get_contents_safe($dynamic_trans_path), true);
+        } else {
+            $dynamic_trans = array();
+        }
+
+        require_code('lang_dynamic_trans');
+        $html->handle_symbol_preprocessing();
+        $html = lang_dynamic_trans($dynamic_trans, $html, user_lang());
+    }
+
     require_code('global4');
     if (
         (!comcode_page_include_on_sitemap($zone, $codename, $comcode_page_row)) &&
diff --git a/sources/site2.php b/sources/site2.php
index 07672dbe..7143d562 100644
--- a/sources/site2.php
+++ b/sources/site2.php
@@ -321,6 +321,45 @@ function page_not_found($codename, $zone)
     return do_template('MISSING_SCREEN', array('_GUID' => '22f371577cd2ba437e7b0cb241931575', 'TITLE' => $title, 'DID_MEAN' => $_did_mean, 'ADD_URL' => $add_url, 'ADD_REDIRECT_URL' => $add_redirect_url, 'PAGE' => $codename));
 }
 
+/**
+ * Load Comcode page from disk.
+ *
+ * @param  PATH $string The relative (to Composr's base directory) path to the page (e.g. pages/comcode/EN/start.txt)
+ * @param  ID_TEXT $zone The zone the page is being loaded from
+ * @param  ID_TEXT $codename The codename of the page
+ * @param  PATH $file_base The file base to load from
+ * @param  MEMBER $page_submitter Page submitter
+ * @param  ?mixed $filter Filter to alter Comcode (null: no filter)
+ * @return array A pair: The page HTML, The page Comcode
+ *
+ * @ignore
+ */
+function __load_comcode_page_from_disk($string, $zone, $codename, $file_base, $page_submitter, $filter = null)
+{
+    $comcode = cms_file_get_contents_safe($file_base . '/' . $string);
+    if (strpos($string, '_custom/') === false) {
+        global $LANG_FILTER_OB;
+        $comcode = $LANG_FILTER_OB->compile_time(null, $comcode);
+    }
+    apply_comcode_page_substitutions($comcode);
+    $comcode = fix_bad_unicode($comcode);
+
+    if ($filter !== null) {
+        $comcode = call_user_func($filter, $comcode);
+    }
+
+    // Parse and work out how to add
+    global $LAX_COMCODE;
+    $temp = $LAX_COMCODE;
+    $LAX_COMCODE = true;
+    require_code('attachments2');
+    $_new = do_comcode_attachments($comcode, 'comcode_page', $zone . ':' . $codename, false, null, false, $page_submitter);
+    $html = $_new['tempcode'];
+    $LAX_COMCODE = $temp;
+
+    return array($html, $comcode);
+}
+
 /**
  * Load Comcode page from disk, then cache it.
  *
@@ -342,47 +381,27 @@ function _load_comcode_page_not_cached($string, $zone, $codename, $file_base, $c
     $nql_backup = $GLOBALS['NO_QUERY_LIMIT'];
     $GLOBALS['NO_QUERY_LIMIT'] = true;
 
-    // Not cached :(
-    $comcode = cms_file_get_contents_safe($file_base . '/' . $string);
-    if (strpos($string, '_custom/') === false) {
-        global $LANG_FILTER_OB;
-        $comcode = $LANG_FILTER_OB->compile_time(null, $comcode);
-    }
-    apply_comcode_page_substitutions($comcode);
-    $comcode = fix_bad_unicode($comcode);
-
-    if (is_null($new_comcode_page_row['p_submitter'])) {
-        $as_admin = true;
+    if ($new_comcode_page_row['p_submitter'] === null) {
         require_code('users_active_actions');
         $new_comcode_page_row['p_submitter'] = get_first_admin_user();
     }
 
-    if (is_null($comcode_page_row)) { // Default page. We need to find an admin to assign it to.
+    if ($comcode_page_row === null) { // Default/manually-constructed page
         $page_submitter = $new_comcode_page_row['p_submitter'];
     } else {
-        $as_admin = false; // Will only have admin privileges if $page_submitter has them
         $page_submitter = $comcode_page_row['p_submitter'];
     }
-    if (is_null($page_submitter)) {
-        $page_submitter = get_member();
-    }
 
-    // Parse and work out how to add
-    $lang = user_lang();
-    global $LAX_COMCODE;
-    $temp = $LAX_COMCODE;
-    $LAX_COMCODE = true;
-    require_code('attachments2');
-    $_new = do_comcode_attachments($comcode, 'comcode_page', $zone . ':' . $codename, false, null, $as_admin/*Ideally we assign $page_submitter based on this as well so it is safe if the Comcode cache is emptied*/, $page_submitter);
-    $_text_parsed = $_new['tempcode'];
-    $LAX_COMCODE = $temp;
+    list($html, $comcode) = __load_comcode_page_from_disk($string, $zone, $codename, $file_base, $page_submitter);
 
     // Flatten for performance reasons?
     if (strpos($comcode, '{$,Quick Cache}') !== false) {
-        $_text_parsed = apply_quick_caching($_text_parsed);
+        $html = apply_quick_caching($html);
     }
 
-    $text_parsed = $_text_parsed->to_assembly();
+    $text_parsed = $html->to_assembly();
+
+    $lang = user_lang();
 
     // Check it still needs inserting (it might actually be there, but not translated)
     $trans_key = $GLOBALS['SITE_DB']->query_select_value_if_there('cached_comcode_pages', 'string_index', array('the_page' => $codename, 'the_zone' => $zone, 'the_theme' => $GLOBALS['FORUM_DRIVER']->get_theme()));
@@ -473,7 +492,7 @@ function _load_comcode_page_not_cached($string, $zone, $codename, $file_base, $c
 
     $GLOBALS['NO_QUERY_LIMIT'] = $nql_backup;
 
-    return array($_text_parsed, $title_to_use, $comcode_page_row, $comcode);
+    return array($html, $title_to_use, $comcode_page_row, $comcode);
 }
 
 /**
@@ -506,40 +525,27 @@ function apply_comcode_page_substitutions(&$comcode)
  */
 function _load_comcode_page_cache_off($string, $zone, $codename, $file_base, $new_comcode_page_row, $being_included = false)
 {
-    global $COMCODE_PARSE_TITLE;
-
-    if (is_null($new_comcode_page_row['p_submitter'])) {
-        $as_admin = true;
-        $members = $GLOBALS['FORUM_DRIVER']->member_group_query($GLOBALS['FORUM_DRIVER']->get_super_admin_groups(), 1);
-        if (count($members) != 0) {
-            $new_comcode_page_row['p_submitter'] = $GLOBALS['FORUM_DRIVER']->mrow_id($members[key($members)]);
-        } else {
-            $new_comcode_page_row['p_submitter'] = db_get_first_id() + 1; // On Conversr and most forums, this is the first admin member
-        }
+    if ($new_comcode_page_row['p_submitter'] === null) {
+        require_code('users_active_actions');
+        $new_comcode_page_row['p_submitter'] = get_first_admin_user();
     }
 
     $_comcode_page_row = $GLOBALS['SITE_DB']->query_select('comcode_pages', array('*'), array('the_zone' => $zone, 'the_page' => $codename), '', 1);
+    $comcode_page_row = array_key_exists(0, $_comcode_page_row) ? $_comcode_page_row[0] : null;
 
-    $comcode = cms_file_get_contents_safe($file_base . '/' . $string);
-    if (strpos($string, '_custom/') === false) {
-        global $LANG_FILTER_OB;
-        $comcode = $LANG_FILTER_OB->compile_time(null, $comcode);
+    if ($comcode_page_row === null) { // Default/manually-constructed page
+        $page_submitter = $new_comcode_page_row['p_submitter'];
+    } else {
+        $page_submitter = $comcode_page_row['p_submitter'];
     }
-    apply_comcode_page_substitutions($comcode);
 
-    global $LAX_COMCODE;
-    $temp = $LAX_COMCODE;
-    $LAX_COMCODE = true;
-    require_code('attachments2');
-    $_new = do_comcode_attachments($comcode, 'comcode_page', $zone . ':' . $codename, false, null, (!array_key_exists(0, $_comcode_page_row)) || (is_guest($_comcode_page_row[0]['p_submitter'])), array_key_exists(0, $_comcode_page_row) ? $_comcode_page_row[0]['p_submitter'] : get_member());
-    $html = $_new['tempcode'];
-    $LAX_COMCODE = $temp;
+    list($html, $comcode) = __load_comcode_page_from_disk($string, $zone, $codename, $file_base, $page_submitter);
+
+    global $COMCODE_PARSE_TITLE;
     $title_to_use = is_null($COMCODE_PARSE_TITLE) ? null : clean_html_title($COMCODE_PARSE_TITLE);
 
     // Try and insert corresponding page; will silently fail if already exists. This is only going to add a row for a page that was not created in-system
-    if (array_key_exists(0, $_comcode_page_row)) {
-        $comcode_page_row = $_comcode_page_row[0];
-    } else {
+    if ($comcode_page_row === null) {
         $comcode_page_row = $new_comcode_page_row;
         $GLOBALS['SITE_DB']->query_insert('comcode_pages', $comcode_page_row, false, true);
 
diff --git a/sources/zones3.php b/sources/zones3.php
index 6d5f1c12..c06a2f63 100644
--- a/sources/zones3.php
+++ b/sources/zones3.php
@@ -651,16 +651,7 @@ function save_comcode_page($zone, $new_file, $lang, $text, $validated = 1, $incl
     }
 
     // Empty caching
-    erase_persistent_cache();
-    //persistent_cache_delete(array('PAGE_INFO')); Already erases above
-    decache('main_comcode_page_children');
-    decache('menu');
-    $caches = $GLOBALS['SITE_DB']->query_select('cached_comcode_pages', array('string_index'), array('the_zone' => $zone, 'the_page' => $file));
-    $GLOBALS['SITE_DB']->query_delete('cached_comcode_pages', array('the_zone' => $zone, 'the_page' => $file));
-    foreach ($caches as $cache) {
-        delete_lang($cache['string_index']);
-    }
-    $GLOBALS['COMCODE_PAGE_RUNTIME_CACHE'] = array();
+    empty_cache_for_comcode_page($zone, $file);
 
     // Log
     log_it('COMCODE_PAGE_EDIT', $new_file, $zone);
@@ -689,6 +680,26 @@ function save_comcode_page($zone, $new_file, $lang, $text, $validated = 1, $incl
     return $full_path;
 }
 
+/**
+ * Empty caching related to a particular Comcode page.
+ *
+ * @param  ID_TEXT $zone The old zone
+ * @param  ID_TEXT $file The old page
+ */
+function empty_cache_for_comcode_page($zone, $file)
+{
+    erase_persistent_cache();
+    //persistent_cache_delete(array('PAGE_INFO')); Already erases above
+    decache('main_comcode_page_children');
+    decache('menu');
+    $caches = $GLOBALS['SITE_DB']->query_select('cached_comcode_pages', array('string_index'), array('the_zone' => $zone, 'the_page' => $file));
+    $GLOBALS['SITE_DB']->query_delete('cached_comcode_pages', array('the_zone' => $zone, 'the_page' => $file));
+    foreach ($caches as $cache) {
+        delete_lang($cache['string_index']);
+    }
+    $GLOBALS['COMCODE_PAGE_RUNTIME_CACHE'] = array();
+}
+
 /**
  * Rename/move a Comcode page.
  * Does create a redirect, if requested.
translation_system.diff (59,679 bytes)   
Time estimation (hours)2
Sponsorship open

Sponsor

Date Added Member Amount Sponsored

Activities

Add Note

View Status
Note
Upload Files
Maximum size: 32,768 KiB

Attach files by dragging & dropping, selecting or pasting them.
You are not logged in You are not logged in. This means you will not get any e-mail notifications. And if you reply, we will not know for sure you are the original poster of the issue.

Issue History

Date Modified Username Field Change
2021-09-27 03:08 Chris Graham New Issue
2021-09-27 03:08 Chris Graham Tag Attached: Roadmap: v12
2021-09-27 03:08 Chris Graham Tag Attached: Type: Internationalisation
2021-11-01 20:12 Chris Graham Tag Attached: Has Patch
2021-11-01 22:22 Chris Graham File Added: translation_system.diff
2022-08-15 16:58 Chris Graham Tag Detached: Roadmap: v12
2022-08-15 16:58 Chris Graham Tag Attached: Roadmap: v11
2022-08-15 20:43 Chris Graham Assigned To => Chris Graham
2022-08-15 20:43 Chris Graham Status Not Assigned => Assigned
2022-11-20 03:02 Chris Graham Tag Detached: Roadmap: v11
2022-11-20 03:03 Chris Graham Tag Attached: Roadmap: v12
2022-11-20 03:03 Chris Graham Assigned To Chris Graham =>
2022-11-20 03:03 Chris Graham Status Assigned => Not Assigned
2023-02-21 02:04 Chris Graham Additional Information Updated
2024-03-26 00:58 PDStig Tag Renamed Roadmap: v12 => Roadmap: Over the horizon