SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / models / LocaleModel.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\models;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\UrlParser;

/**
 * Function for comparing two locale arrays by locale tag so can sort
 *
 * @param array $a an associative array of locale info
 * @param array $b an associative array of locale info
 *
 * @return int -1, 0, or 1 depending on which is alphabetically smaller or if
 *     they are the same size
 */
function lessThanLocale($a, $b)
{
    if ($a["LOCALE_TAG"] == $b["LOCALE_TAG"]) {
        return 0;
    }
    return ($a["LOCALE_TAG"] < $b["LOCALE_TAG"]) ? -1 : 1;
}
/**
 * Used to encapsulate information about a locale (data about a language in
 * a given region).
 *
 * @author Chris Pollett
 */
class LocaleModel extends Model
{
    /**
     * The flag a locale that ships with Yioop starts with, by locale tag:
     * the flag typically associated with the language. createdb and the
     * version 129 database upgrade fill a locale's FLAG from it; a site
     * can change it under Manage Locales. getLocaleInfo and getLocaleList
     * give it for a shipped locale whose FLAG is empty.
     */
    const DEFAULT_FLAGS = [
        "ar" => "🇸🇦",
        "bn" => "🇧🇩",
        "de" => "🇩🇪",
        "el-GR" => "🇬🇷",
        "en-US" => "🇺🇸",
        "es" => "🇪🇸",
        "fa" => "🇮🇷",
        "fr-FR" => "🇫🇷",
        "he" => "🇮🇱",
        "hi" => "🇮🇳",
        "id" => "🇮🇩",
        "it" => "🇮🇹",
        "ja" => "🇯🇵",
        "kn" => "🇮🇳",
        "ko" => "🇰🇷",
        "nl" => "🇳🇱",
        "pl" => "🇵🇱",
        "pt" => "🇧🇷",
        "ru" => "🇷🇺",
        "te" => "🇮🇳",
        "th" => "🇹🇭",
        "tl" => "🇵🇭",
        "tr" => "🇹🇷",
        "vi-VN" => "🇻🇳",
        "zh-CN" => "🇨🇳",
    ];
    /**
     * Used to store ini file data of the current locale
     * @var array
     */
    public $configure = [];
    /**
     * Used to store ini file data of the default locale (will use if no
     * translation current locale)
     * @var array
     */
    public $default_configure = [];
    /**
     * IANA tag name of current locale
     * @var string
     */
    public $locale_tag;
    /**
     * Locale name as a string it locale name's language
     * @var string
     */
    public $locale_name;
    /**
     * Combination of text direction and block progression as a string. Has one
     * of four values: lr-tb, rl-tb, tb-lr, tb-rl. Other possible values for
     * things like Arabic block quoted in Mongolian not supported
     * @var string
     */
    public $writing_mode;
    /**
     * Directories to try to extract translatable identifier strings from
     * @var array
     */
    public $extract_dirs = ["controllers", "views", "library/indexing_plugins"];
    /**
     * File extensions of files to try to extract translatable strings from
     * @var array
     */
    public $extensions = ["php"];
    /**
     * Associations of the form
     *     name of field for web forms => database column names/abbreviations
     * In this case, things will in general map to the LOCALES tables in the
     * Yioop database
     * @var array
     */
    public $search_table_column_map = ["name"=>"LOCALE_NAME",
        "tag"=>"LOCALE_TAG", "mode" => "WRITING_MODE", "active" => "ACTIVE"];
    /**
     * These fields if present in $search_array (used by @see getRows() ),
     * but with value "-1", will be skipped as part of the where clause
     * but will be used for order by clause
     * @var array
     */
    public $any_fields = ["mode", "active"];
    /**
     * Holds global locale object currently in use for the session
     * @var object
     */
    public static $current_locale = null;
    /**
     * {@inheritDoc}
     *
     * @param mixed $args any additional arguments which should be used to
     *     determine the columns
     * @return string a comma separated list of columns suitable for a SQL
     *     query
     */
    public function selectCallback($args = null)
    {
        return "LOCALE_ID, LOCALE_TAG, LOCALE_NAME, WRITING_MODE, ACTIVE, " .
            "FLAG";
    }
    /**
     * This is called after each row is retrieved by getRows. This method
     * Then reads in the corresponding statistics.txt file (or rebuilds
     * it from the Configure.ini file if it is out of date) to add to the
     * row percent translated info.
     *
     * @param string $locale one getRows row corresponding to a given locale
     * @param mixed $args additional arguments that might be used for this
     *     method (none used for this sub-class)
     * @return array $locale row with PERCENT_WITH_STRINGS field added
     */
    public function rowCallback($locale, $args)
    {
        /*
            the statistics text file contains info used to calculate
            what fraction of strings have been translated
         */
        $dir_locale_tag = str_replace("-", "_", $locale['LOCALE_TAG']);
        $tag_prefix = C\LOCALE_DIR . "/" . $dir_locale_tag;
        if (!file_exists($tag_prefix)) {
            L\makePath($tag_prefix); //create locale_dirs that are missing
            $this->db->setWorldPermissionsRecursive($tag_prefix);
        }
        /* The strings are those that ship with Yioop with the site's
           changes in place, so the count is redone when either file is
           newer than the count. */
        $newest_strings = max(
            @filemtime("$tag_prefix/configure.ini") ?: 0,
            @filemtime(C\FALLBACK_LOCALE_DIR .
            "/$dir_locale_tag/configure.ini") ?: 0);
        if (!file_exists("$tag_prefix/statistics.txt") ||
            filemtime("$tag_prefix/statistics.txt") < $newest_strings) {
            $tmp = L\localeConfigure($dir_locale_tag);
            $num_ids = 0;
            $num_strings = 0;
            $strings = (isset($tmp['strings']) &&
                is_array($tmp['strings'])) ? $tmp['strings'] : [];
            foreach ($strings as $msg_id => $msg_string) {
                $num_ids++;
                if (strlen($msg_string) > 0) {
                    $num_strings++;
                }
            }
            if ($num_ids > 0) {
                $locale['PERCENT_WITH_STRINGS'] =
                    floor(100 * $num_strings / $num_ids);
            } else {
                /* A locale folder whose configure.ini has no usable
                   strings section (an empty or stray folder, say) has
                   nothing to translate; report zero rather than divide
                   by zero, and name the folder so it can be cleaned up. */
                $locale['PERCENT_WITH_STRINGS'] = 0;
                L\crawlLog("Locale " . $locale['LOCALE_TAG'] .
                    " has no strings in $tag_prefix/configure.ini");
            }
            file_put_contents("$tag_prefix/statistics.txt",
                serialize($locale['PERCENT_WITH_STRINGS']));
        } else {
            $locale['PERCENT_WITH_STRINGS'] =
                unserialize(
                    file_get_contents("$tag_prefix/statistics.txt"));
        }
        return $locale;
    }
    /**
     * Loads the provided locale's configure file (containing translation) and
     * calls setlocale to set up locale specific string formatting
     * (to format numbers, etc.)
     *
     * @param string $locale_tag  the tag of the locale to use as the current
     *     locale
     * @param bool $fast whether to stop once the strings are loaded,
     *     leaving the locale's name and writing mode unread. A caller
     *     that only translates strings, such as the gate that refuses a
     *     request before any database work, passes true; a page that
     *     lays itself out needs the writing mode and passes false.
     */
    public function initialize($locale_tag, $fast = false)
    {
        $replaced_tag = str_replace("-", "_", $locale_tag);
        $old_style_dir = C\LOCALE_DIR . "/" . $locale_tag;
        $new_style_dir = C\LOCALE_DIR . "/" . $replaced_tag;
        if ($replaced_tag != $locale_tag) {
            // renames locale using old style locale dir naming
            if (file_exists($old_style_dir) && !file_exists($new_style_dir)) {
                rename($old_style_dir, $new_style_dir);
                if  (file_exists($new_style_dir . "/resources/tokenizer.php")) {
                    rename($new_style_dir . "/resources/tokenizer.php",
                        $new_style_dir . "/resources/Tokenizer.php");
                }
            }
        }
        // Code to try to fallback from en to en-US if exists
        if (!file_exists(C\FALLBACK_LOCALE_DIR . "/" . $replaced_tag) &&
            !file_exists($new_style_dir) &&
            substr_count($locale_tag, "-") == 0) {
            $sub_locale_dirs = array_merge(
                glob(C\FALLBACK_LOCALE_DIR . "/$replaced_tag*",
                GLOB_ONLYDIR) ?: [], glob("$new_style_dir*",
                GLOB_ONLYDIR) ?: []);
            if (!empty($sub_locale_dirs[0])) {
                $replaced_tag = basename($sub_locale_dirs[0]);
                $locale_tag = str_replace("_", "-", $replaced_tag);
            }
        }
        $this->configure = L\localeConfigure($replaced_tag);
        if ($locale_tag != C\p('DEFAULT_LOCALE')) {
            $this->default_configure = L\localeConfigure(
                str_replace("-", "_", C\p('DEFAULT_LOCALE')));
        }
        $this->locale_tag = $locale_tag;
        if ($fast) {
            return;
        }
        $db = $this->db;
        $sql = "SELECT LOCALE_NAME, WRITING_MODE ".
            " FROM LOCALE WHERE LOCALE_TAG ='$locale_tag'";
        $result = $db->execute($sql);
        $row = false;
        if ($result) {
            $row = $db->fetchArray($result);
            if (empty($row['LOCALE_NAME']) || empty($row['WRITING_MODE'])) {
                $row = false;
                $this->locale_name = "";
                $this->writing_mode = "lr-tb";
            } else {
                $this->locale_name = $row['LOCALE_NAME'];
                $this->writing_mode = $row['WRITING_MODE'];
            }
        } else {
            $this->locale_name = "";
            $this->writing_mode = "lr-tb";
        }
        $locale_tag_parts = preg_split("/\_|\-/", $locale_tag);
        setlocale(LC_ALL, $locale_tag, $locale_tag . '.UTF-8',
            $locale_tag.'.UTF8',  $locale_tag . ".TCVN", $locale_tag.".VISCII",
            $locale_tag_parts[0], $locale_tag_parts[0].'.UTF-8',
            $locale_tag_parts[0].'.UTF8', $locale_tag_parts[0].".TCVN");
        //hacks for things that didn't work from the above
        if ($locale_tag == 'vi-VN') {
            setlocale(LC_NUMERIC, 'fr_FR.UTF-8');
        }
    }
    /**
     * Returns information about available locales
     *
     * @param bool $only_active says whether to return info only about active
     *      locales (true) or about all locales (false);
     * @param bool $only_db_info only get database information about the locale,
     *      skip calculating percent translated
     * @return array rows of locale information
     */
    public function getLocaleList($only_active = true, $only_db_info = true)
    {
        $db = $this->db;
        $sql = "SELECT LOCALE_ID, LOCALE_TAG, LOCALE_NAME, WRITING_MODE, ".
            "FLAG FROM LOCALE";
        if ($only_active) {
            $sql .= " WHERE ACTIVE > 0";
        }
        $result = $db->execute($sql);
        $i = 0;
        $locales = [];
        while ($locales[$i] = $db->fetchArray($result)) {
            if (empty($locales[$i]['FLAG'])) {
                $locales[$i]['FLAG'] =
                    self::DEFAULT_FLAGS[$locales[$i]['LOCALE_TAG']] ?? "";
            }
            if (!$only_db_info) {
                $locales[$i] = $this->rowCallback($locales[$i], null);
            }
            $i++;
        }
        unset($locales[$i]); //last one will be null
        usort($locales, C\NS . "Models\\lessThanLocale");
        return $locales;
    }
    /**
     * Check if there is a locale with tag equal to $locale_tag
     *
     * @param string $locale_tag to check for
     * @return bool whether or not has exists
     */
    public function checkLocaleExists($locale_tag)
    {
        $db = $this->db;
        $params = [$locale_tag];
        $sql = "SELECT COUNT(*) AS NUM FROM LOCALE WHERE
            LOCALE_TAG=? ";
        $result = $db->execute($sql, $params);
        if (!$row = $db->fetchArray($result)) {
            return false;
        }
        if ($row['NUM'] <= 0) {
            return false;
        }
        return true;
    }
    /**
     * Returns the locale name, tag, and writing mode for tag $locale_tag
     *
     * @param string $locale_tag to get name for
     * @return string name of locale
     */
    public function getLocaleInfo($locale_tag)
    {
        $db = $this->db;
        $params = [$locale_tag];
        $sql = "SELECT * FROM LOCALE WHERE
            LOCALE_TAG=? ";
        $result = $db->execute($sql, $params);
        if (!$row = $db->fetchArray($result)) {
            return "";
        }
        /* A shipped locale with no flag stored, as on a site whose
           upgrade did not fill it, shows the flag it ships with. */
        if (empty($row['FLAG'])) {
            $row['FLAG'] = self::DEFAULT_FLAGS[$row['LOCALE_TAG']] ?? "";
        }
        return $row;
    }
    /**
     * Returns the name of the locale for tag $locale_tag
     *
     * @param string $locale_tag to get name for
     * @return string name of locale
     */
    public function getLocaleName($locale_tag)
    {
        $db = $this->db;
        $params = [$locale_tag];
        $sql = "SELECT LOCALE_NAME FROM LOCALE WHERE
            LOCALE_TAG=? ";
        $result = $db->execute($sql, $params);
        if (!$row = $db->fetchArray($result)) {
            return "";
        }
        return $row["LOCALE_NAME"];
    }
    /**
     * Adds information concerning a new locale to the database
     *
     * @param string $locale_name the name of the locale in the locale's
     *     language
     * @param string $locale_tag the IANA language tag for the locale
     * @param string $writing_mode  a combination of the horizontal and
     *     vertical text direction used for writing in the locale
     * @param int $active whether the locale is current active (>0) or not
     *      used (== 0)
     * @param string $flag the flag shown for the locale in a locale
     *     dropdown, such as an emoji flag; "" for none
     */
    public function addLocale($locale_name, $locale_tag, $writing_mode,
        $active = 1, $flag = "")
    {
        $sql = "INSERT INTO LOCALE (LOCALE_NAME, LOCALE_TAG,
            WRITING_MODE, ACTIVE, FLAG) VALUES (?, ?, ?, ?, ?)";
        $this->db->execute($sql, [$locale_name, $locale_tag,
            $writing_mode, $active, $flag]);
        $locale_tag = str_replace("-", "_", $locale_tag);
        if (!file_exists(C\LOCALE_DIR."/$locale_tag")) {
            L\makePath(C\LOCALE_DIR."/$locale_tag");
            $this->db->setWorldPermissionsRecursive(
                C\LOCALE_DIR."/$locale_tag");
        }
    }
    /**
     * Remove a locale from the database
     *
     * @param string $locale_tag the IANA language tag for the locale to remove
     */
    public function deleteLocale($locale_tag)
    {
        $sql = "DELETE FROM LOCALE WHERE LOCALE_TAG = ?";
        $this->db->execute($sql, [$locale_tag]);
        if (file_exists(C\LOCALE_DIR . "/$locale_tag")) {
            $this->db->unlinkRecursive(C\LOCALE_DIR . "/$locale_tag", true);
        }
    }
    /**
     * Used to update the fields stored in a LOCALE row according to
     * an array holding new values
     *
     * @param array $locale_info updated values for a LOCALE row
     */
    public function updateLocaleInfo($locale_info)
    {
        $locale_id = $locale_info['LOCALE_ID'];
        unset($locale_info['LOCALE_ID']);
        unset($locale_info['LOCALE_NAME']);
        $sql = "UPDATE LOCALE SET ";
        $comma ="";
        $params = [];
        foreach ($locale_info as $field => $value) {
            $sql .= "$comma $field=? ";
            $comma = ",";
            $params[] = $value;
        }
        $sql .= " WHERE LOCALE_ID=?";
        $params[] = $locale_id;
        $this->db->execute($sql, $params);
    }
    /**
     * For each translatable identifier string (either static from a
     * configure ini file, or dynamic from the db)
     * return its name together with its translation into the given locale
     * if such a translation exists.
     *
     * @param string $locale_tag the IANA language tag to translate string into
     * @return array  rows of identifier string - translation pairs
     */
    public function getStringData($locale_tag)
    {
        $db = $this->db;
        $dir_locale_tag = str_replace("-", "_", $locale_tag);
        $data = L\localeConfigure($dir_locale_tag)['strings'] ?? [];

        //hacky. Join syntax isn't quite the same between sqlite and mysql
        if (in_array(C\p('DBMS'), ['sqlite3'])) {
            $sql = "SELECT T.IDENTIFIER_STRING AS MSG_ID, ".
                "TLL.TRANSLATION AS MSG_STRING " .
                "FROM TRANSLATION T LEFT JOIN ".
                //sqlite supports left but not right outer join
                "(TRANSLATION_LOCALE TL JOIN LOCALE L ON ".
                "L.LOCALE_TAG = ? AND ".
                "L.LOCALE_ID = TL.LOCALE_ID) TLL " .
                "ON T.TRANSLATION_ID = TLL.TRANSLATION_ID";
        } else {
            $sql = "SELECT T.IDENTIFIER_STRING AS MSG_ID, ".
                "TL.TRANSLATION AS MSG_STRING " .
                "FROM TRANSLATION T LEFT JOIN ".
                "(TRANSLATION_LOCALE TL JOIN LOCALE L ON ".
                "L.LOCALE_TAG = ? AND L.LOCALE_ID = TL.LOCALE_ID) ".
                "ON T.TRANSLATION_ID = TL.TRANSLATION_ID";
        }
        $result = $this->db->execute($sql, [$locale_tag]);
        while ($row = $this->db->fetchArray($result)) {
            $data[$row['MSG_ID']] = $row['MSG_STRING'];
        }
        return $data;
    }
    /**
     * Updates the identifier_string-translation pairs
     * (both static and dynamic) for a given locale
     *
     * @param string $locale_tag  the IANA language tag to update the strings of
     * @param array $new_strings  rows of identifier string - translation pairs
     */
    public function updateStringData($locale_tag, $new_strings)
    {
        $db = $this->db;
        $sql = "SELECT LOCALE_ID FROM LOCALE WHERE LOCALE_TAG = ? " .
            $db->limitOffset(1);
        $result = $db->execute($sql, [$locale_tag]);
        $row = $db->fetchArray($result);
        $locale_id = $row['LOCALE_ID'];

        list($general_ini, $strings) = $this->extractMergeLocales();
        $select_sql = "SELECT TRANSLATION_ID FROM TRANSLATION ".
            "WHERE IDENTIFIER_STRING = ? " . $db->limitOffset(1);
        $delete_sql = "DELETE FROM TRANSLATION_LOCALE ".
            "WHERE TRANSLATION_ID =? AND LOCALE_ID = ?";
        $insert_sql = "INSERT INTO TRANSLATION_LOCALE VALUES (?, ?, ?)";
        foreach ($new_strings as $msg_id => $msg_string) {
            if (strcmp($msg_id, strstr($msg_id, "db_")) == 0) {
                $result = $db->execute($select_sql, [$msg_id]);
                $row = $db->fetchArray($result);
                $translate_id = $row['TRANSLATION_ID'];
                $result = $db->execute($delete_sql, [$translate_id,
                    $locale_id]);
                $result = $db->execute($insert_sql, [$translate_id,
                    $locale_id, $msg_string]);
                $new_strings[$msg_id] = false;
            }
        }
        array_filter($new_strings);
        $data['strings'] = $new_strings;
        $data['strings']["view_locale_version"] = C\RESOURCES_WIKI_VERSION;
        $dir_locale_tag = str_replace("-", "_", $locale_tag);
        $this->updateLocale(
            $general_ini, $strings, C\LOCALE_DIR, $dir_locale_tag, $data);
    }
    /**
     * Translate an array consisting of an identifier string together with
     *     additional variable parameters into the current locale.
     *
     * Suppose the identifier string was some_view_fraction_received and two
     * additional arguments 5 and 10 were given. Suppose further that its
     * translation into the current locale (say en_US) was "%s out of %s".
     * Then the string returned by translate would be "5 out of 10".
     *
     * @param array $arr an array consisting of an identifier string followed
     *     optionally by parameter values.
     * @return mixed the translation of the identifier string into the
     *     current locale where all %s have been replaced by the corresponding
     *     parameter values. Returns false if no translation
     */
    public function translate($arr)
    {
        if (!is_array($arr)) {
            return;
        }
        $num_args = count($arr);
        if ($num_args < 1) {
            return;
        }
        $msg_id = $arr[0];

        $args = array_slice($arr, 1);
        $msg_string = false;
        if (isset($this->configure['strings'][$msg_id])) {
            $msg_string = $this->configure['strings'][$msg_id];
        }
        if ($msg_string == "" &&
            isset($this->default_configure['strings'][$msg_id])) {
            $msg_string = $this->default_configure['strings'][$msg_id];
        }
        if ($msg_string !== false) {
            $num_string_args = substr_count($msg_string, "%s");
            if ($num_string_args >= $num_args) {
                $tmp_args = array_fill(0, $num_string_args + 1
                    - $num_args, "%s");
                $args = array_merge($args, $tmp_args);
            }
            $msg_string = vsprintf($msg_string, $args);
        }
        return $msg_string;
    }
    /**
     * Get the current IANA language tag being used by the search engine
     *
     * @return string an IANA language tag
     */
    public function getLocaleTag()
    {
        return $this->locale_tag;
    }
    /**
     * The text direction of the current locale being used by the text engine
     *
     * @return string either ltr (left-to-right) or rtl (right-to-left)
     */
    public function getLocaleDirection()
    {
        return match ($this->writing_mode) {
            "rl-tb", "tb-rl" => "rtl",
            default => "ltr",
        };
    }
    /**
     * Get the writing mode of the current locale (text and block directions)
     *
     * @return string the current writing mode
     */
    public function getWritingMode()
    {
        return $this->writing_mode;
    }
    /**
     * Used to extract identifier strings from files with correct extensions,
     * then these strings are merged with existing extracted strings for each
     * locale as well as their translations (if an extract string has a
     * translation the translation is untouched by this process).
     *
     * @return array a pair consisting of the data from the General.ini file
     *     together with an array of msg_ids msg_strings.
     */
    public function extractMergeLocales()
    {
        $list = $this->getLocaleList(true, false);
        // getLocaleList will also create any missing locale dirs
        $strings =
            $this->getTranslateStrings($this->extract_dirs, $this->extensions);
        $general_name = file_exists(C\LOCALE_DIR . "/general.ini") ?
            C\LOCALE_DIR . "/general.ini" :
            C\FALLBACK_LOCALE_DIR . "/general.ini";
        $general_ini = L\parse_ini_with_fallback($general_name);
        $this->updateLocales($general_ini, $strings);
        return [$general_ini, $strings];
    }
    /**
     * Cycles through locale subdirectories in LOCALE_DIR, for each
     * locale it merges out the current general_ini and strings data.
     * It deletes identifiers that are not in strings, it adds new identifiers
     * and it leaves existing identifier translation pairs untouched.
     *
     * @param array $general_ini  data that would typically come from the
     *     General.ini file
     * @param array $strings lines from what is equivalent to an ini file
     *     of msg_id msg_string pairs these lines also have comments on the
     *     file that strings were extracted from
     */
    public function updateLocales($general_ini, $strings)
    {
        $path = C\LOCALE_DIR;
        if (realpath($path) === realpath(C\FALLBACK_LOCALE_DIR)) {
            return;
        }
        L\makePath($path);
        if (!$dh = @opendir($path)) {
            die("Couldn't read locale directory!\n");
        }
        while (($obj = readdir($dh)) !== false) {
            if ($obj[0] == '.') {
                continue;
            }
            $cur_path = $path . '/' . $obj;
            if (is_dir($cur_path)) {
                $this->updateLocale($general_ini, $strings, $path, $obj);
            }
        }
    }
    /**
     * Updates the Configure.ini file and static pages for a particular locale.
     *
     * The Configure.ini has general information (at this point not really
     * being used) about all locales together with specific msg_id (identifiers
     * to be translated) and msg_string (translation) data. updateLocale takes
     * line data coming from the General.ini file, strings extracted from
     * documents that might need to be translation, the old Configure.ini file
     * (this might have existing translations),  as well as new translation
     * data that might come from a localizer via a web form and
     * combines these to produce a new Configure.ini file
     *
     * @param array $general_ini data from the General.ini file
     * @param array $strings line array data extracted from files in
     *     directories that have strings in need of translation
     * @param string $dir the directory of all the locales
     * @param string $locale the particular locale in $dir to update
     * @param array $new_configure translations of identifier strings from
     *     another source such as a localizer using a web form
     */
    public function updateLocale($general_ini, $strings,
        $dir, $locale, $new_configure = null)
    {
        /* The locales that ship with Yioop are never written to. A site
           with no work directory has nowhere to keep changes. */
        if (realpath($dir) === realpath(C\FALLBACK_LOCALE_DIR)) {
            return;
        }
        $old_configure = [];
        $cur_path = $dir . '/' . $locale;
        L\makePath($cur_path);
        if (file_exists($cur_path.'/configure.ini')) {
            $old_configure = L\parse_ini_with_fallback(
                $cur_path.'/configure.ini');
        }
        $fallback_path = C\FALLBACK_LOCALE_DIR. '/' . $locale;
        $fallback_configure = [];
        if (file_exists($fallback_path . '/configure.ini')) {
            $fallback_configure = L\parse_ini_with_fallback(
                $fallback_path . '/configure.ini');
        }
        /* A settings file this PHP will not read gives back nothing
           rather than a list of settings, and writing a section into
           nothing is a fault on a newer PHP. Reading on with an empty
           list writes the file out from what the other two hold. */
        $old_configure = is_array($old_configure) ? $old_configure : [];
        $fallback_configure = is_array($fallback_configure) ?
            $fallback_configure : [];
        $n = [];
        $n[] = <<<EOT
; ***** BEGIN LICENSE BLOCK *****
;  SeekQuarry/Yioop Open Source Pure PHP Search Engine, Crawler, and Indexer
;  Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
;
;  This program is free software: you can redistribute it and/or modify
;  it under the terms of the GNU General Public License as published by
;  the Free Software Foundation, either version 3 of the License, or
;  (at your option) any later version.
;
;  This program is distributed in the hope that it will be useful,
;  but WITHOUT ANY WARRANTY; without even the implied warranty of
;  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;  GNU General Public License for more details.
;
;  You should have received a copy of the GNU General Public License
;  along with this program.  If not, see <https://www.gnu.org/licenses/>.
;  ***** END LICENSE BLOCK *****
;
; configure.ini
;
; $locale configuration file
;
EOT;
        foreach ($general_ini as $general_name => $general_value) {
            if (is_array($general_value)) {
                $n[] = "[$general_name]";
                $new_configure[$general_name] =
                    $new_configure[$general_name] ?? null;
                $old_configure[$general_name] =
                    $old_configure[$general_name] ?? [];
                $fallback_configure[$general_name] =
                    $fallback_configure[$general_name] ?? [];
                foreach ($general_value as $name => $value) {
                    $n[] = $this->changedTranslation(
                        $new_configure[$general_name],
                        $old_configure[$general_name],
                        $fallback_configure[$general_name],
                        $name, $value);
                }
            } else {
                $n[] = $this->changedTranslation($new_configure,
                    $old_configure, $fallback_configure, $general_name, "");
            }
        }
        $n[] = ";\n; Strings to translate on various pages\n;";
        $n[] = "[strings]";
        foreach ($strings as $string) {
            if (!isset($string[0]) || $string[0] != ";") {
                $new_configure['strings'] =
                    $new_configure['strings'] ?? null;
                $old_configure['strings'] = $old_configure['strings'] ?? [];
                $fallback_configure['strings'] =
                    $fallback_configure['strings'] ?? [];
                $n[] = $this->changedTranslation($new_configure['strings'],
                    $old_configure['strings'], $fallback_configure['strings'],
                    $string, "");
            }
        }
        $n = array_filter($n, fn($line) => $line !== "");
        $out = implode("\n", $n);
        $out .= "\n";
        file_put_contents($cur_path . '/configure.ini', $out);
    }
    /**
     * changedTranslation gives the line a configure.ini file holds for one
     * string id, as updateTranslation makes it, or the empty string when
     * the line is left out. updateLocale calls it for each string of a
     * site's locale. A line is kept only when its translation is not empty
     * and differs from the one that ships with Yioop, since the shipped
     * one is read anyway. The line
     * view_locale_version, which records which version of the strings the
     * site was last brought up to, is always kept.
     *
     * @param array $new_configure string_id => translation pairs, such as
     *      those an administrator just saved
     * @param array $old_configure string_id => translation pairs from the
     *      file being rewritten
     * @param array $fallback_configure string_id => translation pairs
     *      that ship with Yioop
     * @param string $string_id the id of the string
     * @param string $default_value value to use if no configuration
     *     has a translation for a string_id
     * @return string the line for the string, or "" to leave it out
     */
    public function changedTranslation($new_configure, $old_configure,
        $fallback_configure, $string_id, $default_value)
    {
        if ($string_id != "view_locale_version") {
            $said = $this->lookupTranslation($new_configure,
                $old_configure, $fallback_configure, $string_id,
                $default_value);
            if ($said === "" ||
                $said === ($fallback_configure[$string_id] ?? $default_value)) {
                return "";
            }
        }
        return $this->updateTranslation($new_configure, $old_configure,
            $fallback_configure, $string_id, $default_value);
    }
    /**
     * Computes a string of the form string_id = 'translation' for a string_id
     * from among translation array data in $new_configure (most preferred,
     * probably come from recent web form data), $old_configure
     * (probably from work dir), and $fallback_configure (probably from base
     * dir of Yioop instance, least preferred).
     *
     * @param array $new_configure string_id => translation pairs
     * @param array $old_configure string_id => translation pairs
     * @param array $fallback_configure string_id => translation pairs
     * @param string $string_id an id to translate
     * @param string $default_value value to use if no configuration
     *     has a translation for a string_id
     * @return string translation in format describe above
     */
    public function updateTranslation($new_configure, $old_configure,
        $fallback_configure, $string_id, $default_value = "")
    {
        $said = $this->lookupTranslation($new_configure,
            $old_configure, $fallback_configure, $string_id,
            $default_value);
        $translation = $string_id . ' = "' .
            str_replace(['\\\\', '"'], ['\\\\\\\\', '\\\\"'], $said) . '"';
        return $translation;
    }
    /**
     * Translates a string_id from among translation array data in
     * $new_configure (most preferred, probably come from recent web form
     * data), $old_configure  (probably from work dir), and $fallback_configure
     * (probably from base  dir of Yioop instance, least preferred).
     *
     * @param array $new_configure string_id => translation pairs
     * @param array $old_configure string_id => translation pairs
     * @param array $fallback_configure string_id => translation pairs
     * @param string $string_id an id to translate
     * @param string $default_value value to use if no configuration
     *     has a translation for a string_id
     * @return string translation of string id
     */
    public function lookupTranslation($new_configure, $old_configure,
        $fallback_configure, $string_id, $default_value = "")
    {
        $new_translation = $this->isTranslated($string_id, $new_configure);
        $old_translation = $this->isTranslated($string_id, $old_configure);
        if ($new_translation || (isset($new_configure[$string_id]) &&
            $new_configure[$string_id] === "" && $old_translation)) {
            if ($string_id == "view_locale_version") {
                $translation = C\RESOURCES_WIKI_VERSION;
            } else {
                $translation = $new_configure[$string_id];
            }
        } elseif ($this->isTranslated($string_id, $old_configure)) {
            $translation = $old_configure[$string_id];
        } elseif ($this->isTranslated($string_id, $fallback_configure)) {
            $translation = $fallback_configure[$string_id];
        } else {
            $translation = $default_value;
        }
        return $translation;
    }
    /**
     * Checks if the given string_id has a translation in translations
     *
     * @param string $string_id what to check if translated
     * @param array $translations of form string_id => translation
     *     defaults to current configuration
     * @return bool whether a translation of nonzero length exists
     */
    public function isTranslated($string_id, $translations = false)
    {
        if ($string_id == "view_locale_version") {
            return true;
        }
        if ($translations === false) {
            $translations = $this->configure['strings'];
        }
        return isset($translations[$string_id]) &&
            strlen($translations[$string_id]) > 0;
    }
    /**
     * removeShippedCopies takes out of the work directory's app/locale the
     * resource files that are only copies of those that ship with Yioop
     * in src/locale. Earlier versions of Yioop copied every locale's
     * resources folder there, and on each upgrade renamed the old copy to
     * a folder named resources followed by a time and "old". Those folders
     * are deleted. A locale's resources folder is deleted only when every
     * file in it has the same bytes as the file of that name the locale
     * ships with. A resources folder holding any file the site changed or
     * added is left exactly as it is. A general.ini that copies the
     * shipped one is deleted too. upgradeLocales calls this before it
     * rewrites each configure.ini with only the strings the site changed.
     *
     * @param string $locale_dir folder of the site's locales, by default
     *      the work directory's app/locale
     * @return array the paths deleted, so a caller can say what was done
     */
    public function removeShippedCopies($locale_dir = C\LOCALE_DIR)
    {
        $removed = [];
        if (realpath($locale_dir) === realpath(C\FALLBACK_LOCALE_DIR)) {
            return $removed;
        }
        $general_name = "$locale_dir/general.ini";
        if (is_file($general_name) && md5_file($general_name) ===
            md5_file(C\FALLBACK_LOCALE_DIR . "/general.ini")) {
            unlink($general_name);
            $removed[] = $general_name;
        }
        foreach (glob($locale_dir . "/*", GLOB_ONLYDIR) ?: [] as
            $locale_path) {
            foreach (glob("$locale_path/resources*old", GLOB_ONLYDIR) ?: []
                as $old_path) {
                $this->db->unlinkRecursive($old_path, true);
                $removed[] = $old_path;
            }
            $shipped_path = C\FALLBACK_LOCALE_DIR . "/" .
                basename($locale_path) . "/resources";
            $only_copies = is_dir("$locale_path/resources");
            foreach (glob("$locale_path/resources/*") ?: [] as $file_path) {
                $shipped_file = "$shipped_path/" . basename($file_path);
                if (!is_file($file_path) || !is_file($shipped_file) ||
                    filesize($file_path) != filesize($shipped_file) ||
                    md5_file($file_path) !== md5_file($shipped_file)) {
                    $only_copies = false;
                    break;
                }
            }
            if ($only_copies) {
                $this->db->unlinkRecursive("$locale_path/resources", true);
                $removed[] = "$locale_path/resources";
            }
        }
        return $removed;
    }
    /**
     * Searches the directories provided looking for files matching the
     * extensions provided. When such a file is found it is loaded and scanned
     * for tl() function calls. The identifier string in this function call is
     * then extracted and added to a line array of strings to be translated.
     * This line array is formatted so that each line looks like a line that
     * might occur in an PHP ini file. To understand this format one can look at
     * the parse_ini_string function in the PHP manual or look at the
     * Configure.ini files in the locale directory
     *
     * @param array $extract_dirs directories to start looking for files with
     *     strings to be translated
     * @param array $extensions file extensions of files which might contain
     *     such strings
     * @return array of lines for any ini file of msg_id msg_string pairs
     */
    public function getTranslateStrings($extract_dirs, $extensions)
    {
        $strings = [];
        $base_dirs = [C\BASE_DIR, C\APP_DIR];
        foreach ($extract_dirs as $dir) {
            foreach ($base_dirs as $base_dir) {
                $path = $base_dir."/".$dir;
                $dir_strings =
                    $this->traverseExtractRecursive($path, $extensions);
                if (count($dir_strings) > 0) {
                    $out_path = substr($path, strlen(C\PARENT_DIR));
                    $strings[] = ";";
                    $strings[] = "; $out_path";
                    $strings = array_merge($strings, $dir_strings);
                }
            }
        }
        return $strings;
    }
    /**
     * Traverses a directory and its subdirectories looking for files
     * whose extensions come from the extensions array. As the traversal
     * is done a strings array is created. Each time a file is found of
     * any identifiers of strings that need to be translated are added to
     * the strings array. In addition, ini style comments are added givne the
     * line file and line number of the item to be translated
     *
     * @param string $dir current directory to start looking for files with
     *     strings to be translated
     * @param array $extensions  file extensions of files which might contain
     *     such strings
     * @return array of lines for any ini file of msg_id msg_string pairs
     */
    public function traverseExtractRecursive($dir, $extensions)
    {
        $strings = [];
        if (!is_dir($dir) || !$dh = @opendir($dir)) {
            return [];
        }
        while (($obj = readdir($dh)) !== false) {
            if ($obj == '.' || $obj == '..') {
                continue;
            }
            $cur_path = $dir . '/' . $obj;
            if (is_dir($cur_path)) {
                $dir_strings =
                    $this->traverseExtractRecursive($cur_path, $extensions);
                if (count($dir_strings) > 0) {
                    $out_path = substr($cur_path, strlen(C\PARENT_DIR));
                    $strings[] = ";";
                    $strings[] = "; $out_path";
                    $strings = array_merge($strings, $dir_strings);
                }
            }
            if (is_file($cur_path)) {
                $path_parts = pathinfo($cur_path);
                $extension = (isset($path_parts['extension'])) ?
                    $path_parts['extension'] : "";
                if (in_array($extension, $extensions)) {
                    $contents = file_get_contents($cur_path);
                    $out_strings = [];
                    $num_matches = preg_match_all('/tl\(\s*' .
                        '[\'|\"]?([[:word:]]+?)[\'|\"]?[(\))|(\s+\,)]/',
                        $contents, $to_translates);
                    if ($num_matches > 0) {
                        $out_strings = $to_translates[1];
                    }
                    $out_strings = array_unique($out_strings);
                    if (!empty($out_strings)) {
                        $strings[] = ";";
                        $strings[] = "; $obj";
                        $strings = array_merge($strings, $out_strings);
                    }
                }
            }
        }
        closedir($dh);
        return $strings;
    }
}