SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / UpgradeFunctions.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
 *
 * This file contains global functions to check whether
 * upgrading the database or locales is needed as wells as auxiliary functions
 * to be used by the VersionFunctions.php code to actually carry out
 * upgrades between versions
 *
 * @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\library;

use seekquarry\atto as A;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\models as M;
use seekquarry\yioop\models\datasources as D;
use seekquarry\yioop\library\wiki\WikiPagesFile;

/** For Yioop global defines */
require_once __DIR__."/../configs/Config.php";
/**
 * upgradeLocalesCheck says whether a locale's data in the work directory
 * is older than the data that ships with the running Yioop. The upgrade
 * step calls it before changing anything, so an untouched locale is left
 * alone. A locale the site changed nothing in has no file of its own and
 * needs nothing done.
 *
 * @param string $locale_tag locale to check directory of
 * @return mixed string "locale" when the on-disk configure.ini is older
 *      than the fallback or the locale version-marker disagrees; false
 *      when the locale is up-to-date; void when no profile is configured
 */
function upgradeLocalesCheck($locale_tag)
{
    /* With no work directory, LOCALE_DIR is the source tree, which an
       upgrade must never write to. */
    if (!C\PROFILE ||
        realpath(C\LOCALE_DIR) === realpath(C\FALLBACK_LOCALE_DIR)) {
        return false;
    }
    $dir_locale_tag = str_replace("-", "_", $locale_tag);
    $config_name = C\LOCALE_DIR . "/$dir_locale_tag/configure.ini";
    $fallback_config_name =
        C\FALLBACK_LOCALE_DIR . "/$dir_locale_tag/configure.ini";
    /* A site that changed no string of this locale has no file of its
       own to bring up to date. */
    if (!file_exists($config_name) || !file_exists($fallback_config_name)) {
        return false;
    }
    if (tl('view_locale_version') != C\RESOURCES_WIKI_VERSION ||
        filemtime($fallback_config_name) > filemtime($config_name)) {
        return "locale";
    }
    return false;
}
/**
 * upgradeLocales brings the work directory's app/locale and the site's
 * wiki and help pages up to the Yioop that is running. app/locale holds
 * only what the site changed: the strings and resources that ship with
 * Yioop are read from src/locale, which nothing writes to. So this deletes
 * old resources folders, and a resources folder whose every file copies a
 * shipped one; a resources folder holding a file the site changed is left
 * as it is. It then rewrites each locale's configure.ini with only the
 * strings that differ from the shipped ones. It runs when the site's
 * strings are older
 * than the running Yioop, and on the upgrade-locales command. The wiki
 * and help pages are pushed out only when the version the site was last
 * brought up to differs from the running one, or when $force is true.
 *
 * @param bool $force when true, push the wiki and help pages even if the
 *      stored resources-wiki version already matches the running one.
 *      This lets a developer see edited help pages without bumping the
 *      resources-wiki version on every change.
 * @param bool $overwrite_edits when true, a wiki or help page edited on
 *      this site is replaced by the shipped page too; otherwise such a
 *      page is kept
 */
function upgradeLocales($force = false, $overwrite_edits = false)
{
    /* Upgrading writes into LOCALE_DIR; when that is the source tree,
       because there is no work directory, nothing is written. */
    if (!C\PROFILE ||
        realpath(C\LOCALE_DIR) === realpath(C\FALLBACK_LOCALE_DIR)) {
        return;
    }
    $locale = new M\LocaleModel();
    $locale->initialize(C\p('DEFAULT_LOCALE'));
    /* The version the site was last brought up to is read from the
       site's own file, since the shipped file always holds the running
       version. */
    $site_configure = parse_ini_with_fallback(C\LOCALE_DIR . "/" .
        str_replace("-", "_", C\p('DEFAULT_LOCALE')) . "/configure.ini");
    $site_version = is_array($site_configure) ?
        ($site_configure['strings']["view_locale_version"] ?? "") : "";
    if ($force || $site_version != C\RESOURCES_WIKI_VERSION) {
        upgradePublicHelpWiki($locale->db, $overwrite_edits);
    }
    $locale->removeShippedCopies();
    $locale->extractMergeLocales();
}
/**
 * upgradePublicHelpWiki writes the public and help wiki pages that ship
 * with Yioop into the database of the site, making a page that is not
 * there and replacing one that is. upgradeLocales calls it, and hands it
 * the connection it is already using. A page the file gives a time for
 * is saved with that time, and a page the file gives its thread's posts
 * for is given those posts in place of the ones its thread held, so an
 * import of another site's export makes the same pages with the same
 * dates and comments.
 *
 * @param resource &$db the open connection to the site's database; the
 *      models this function makes are given it rather than opening one
 *      of their own
 * @param bool $overwrite_edits when true, a page whose latest revision
 *      was edited on this site is replaced by the shipped page as well;
 *      otherwise only pages last written by an upgrade or at creation
 *      are replaced
 * @param string $pages_file the file of pages to write, in the form of
 *      PublicHelpPages.php; by default the one that ships with Yioop.
 *      ConfigureTool's import-public-help gives the one in the work
 *      directory where there is one.
 * @param bool $wipe_first when true, the Public and Help groups are left
 *      holding just the file's pages: once the file's pages are written,
 *      in the same transaction, every other page is deleted. Only the
 *      kinds of data the file carries are deleted with it, so where the
 *      file has no git issue pages the issue pages stay, where it has no
 *      comments the discussion threads stay, and where it has no history
 *      the history stays, as WikiModel::wipeGroupWiki says. A group
 *      whose array the file does not declare, as in an export of just the
 *      Public or just the Help group, is left as it was.
 */
function upgradePublicHelpWiki(&$db, $overwrite_edits = false,
    $pages_file = C\BASE_DIR . "/configs/PublicHelpPages.php",
    $wipe_first = false)
{
    /* Both models are given the caller's open connection. A model made
       with connecting turned off holds a manager with no host, user or
       database name, and the manager reconnects with those empty values
       the first time a query runs, which a PDO data source refuses. The
       wiki model used to be left that way, so a site whose database is
       reached through PDO, such as Postgres, could not upgrade its
       locales. */
    $group_model = new M\GroupModel(C\p('DB_NAME'), false);
    $wiki_model = new M\WikiModel(C\p('DB_NAME'), false);
    $group_model->db = $db;
    $wiki_model->db = $db;
    $default_locale = getLocaleTag();
    /* The thousands of page writes below go in one transaction, so the
       database reaches the disk once rather than after every write. */
    $db->beginTransaction();
    $kept_page_ids = [C\PUBLIC_GROUP_ID => [], C\HELP_GROUP_ID => []];
    /* The pages are taken from the file one at a time and written as they
       come, so a site's export of many pages is never held whole. */
    $current_locale = "";
    foreach (WikiPagesFile::eachPage($pages_file) as [$array_name,
        $locale_tag, $page_name, $page_content, $pubdate, $comments,
        $history]) {
        if (!in_array($array_name, ["public_pages", "help_pages"])) {
            continue;
        }
        if ($locale_tag !== $current_locale) {
            setLocaleObject($locale_tag);
            $current_locale = $locale_tag;
        }
        $group_id = ($array_name == "public_pages") ? C\PUBLIC_GROUP_ID :
            C\HELP_GROUP_ID;
        $page_info = $wiki_model->getPageInfoByName($group_id, $page_name,
            $locale_tag, "edit");
        if ($array_name == "public_pages") {
            $replace = empty($page_info) ||
                ($page_content != $page_info['PAGE'] && ($overwrite_edits ||
                in_array($page_info['EDIT_COMMENT'], ['upgrade', 'create'])))||
                (!empty($page_info['PUBDATE']) &&
                $page_info['PUBDATE'] < 1555802108);
        } else {
            $replace = empty($page_info) ||
                ($page_content != $page_info['PAGE'] && ($overwrite_edits ||
                in_array($page_info['EDIT_COMMENT'], ['upgrade', 'create']) ||
                empty($page_info['PUBDATE']) ||
                $page_info['PUBDATE'] < 1593297907));
        }
        $replace = $replace || ($wipe_first &&
            $page_content != $page_info['PAGE']);
        /* A page already holding the file's revision, the same text saved
           at the same time, is not saved again: that would put the
           importer's name on the revision in place of the person who made
           it. */
        if (!empty($page_info) && $pubdate > 0 &&
            $page_content == $page_info['PAGE'] &&
            intval($page_info['PUBDATE'] ?? 0) == $pubdate) {
            $replace = false;
        }
        if ($replace) {
            $wiki_model->setPageName(C\ROOT_ID, $group_id, $page_name,
                $page_content, $locale_tag, "upgrade",
                tl('social_component_page_created', $page_name),
                tl('social_component_page_discuss_here'),
                M\WikiModel::WIKI_LINK_BASE, true, $pubdate);
        }
        if ($comments !== null) {
            $wiki_model->setPageComments($group_id, $page_name, $locale_tag,
                $comments);
        }
        if ($history !== null && ($replace || $wipe_first)) {
            $wiki_model->setPageHistory($group_id, $page_name, $locale_tag,
                $history);
        }
        $kept_page_ids[$group_id][] = $wiki_model->getPageId($group_id,
            $page_name, $locale_tag);
    }
    if ($wipe_first) {
        /* A file of just one group's pages, as an export of only the
           Public or only the Help group writes, leaves the other group as
           it was. */
        $parts = WikiPagesFile::exportedParts($pages_file);
        $groups = WikiPagesFile::exportedGroups($pages_file);
        $group_arrays = [C\PUBLIC_GROUP_ID => "public_pages",
            C\HELP_GROUP_ID => "help_pages"];
        foreach ($kept_page_ids as $group_id => $page_ids) {
            if (in_array($group_arrays[$group_id], $groups)) {
                $wiki_model->wipeGroupWiki($group_id, $page_ids, $parts);
            }
        }
    }
    $db->commit();
    setLocaleObject($default_locale);
}
/**
 * upgradeDatabaseWorkDirectoryCheck says whether the database or the
 * work directory of this site is from an
 * older version of Yioop! than the currently running Yioop!
 *
 * @return bool true when the on-disk VERSION is older than the
 *      runtime DATABASE_VERSION (an upgrade is required), false when
 *      the database is up-to-date; falls through to subsequent code
 *      paths when the VERSION query fails on all retry attempts
 */
function upgradeDatabaseWorkDirectoryCheck()
{
    $model = new M\Model();
    $sql = "SELECT ID FROM VERSION";
    set_error_handler(null);
    for ($i = 0; $i < 3; $i++) {
        $result = @$model->db->execute($sql);
        if ($result !== false) {
            $row = $model->db->fetchArray($result);
            if ((isset($row['ID']) && $row['ID'] >= C\DATABASE_VERSION) ||
                (isset($row['id']) && $row['id'] >= C\DATABASE_VERSION)) {
                return false;
            } else {
                return true;
            }
        }
        sleep(3);
    }
    restore_error_handler();
    A\webExit();
}
/**
 * holdUpgradeLock waits until this request holds the upgrade lock, which
 * only one request at a time can hold. A request calls it before it
 * upgrades the database or the locales, so the requests that arrive while
 * an upgrade runs wait for it rather than running the same upgrade at the
 * same time. A request that has waited UPGRADE_WAIT seconds stops with a
 * page saying the site is being upgraded.
 *
 * @return resource|false the open lock file, which the caller keeps until
 *      its upgrade is done; the lock is let go when the file is closed or
 *      the request ends. False where the lock file could not be opened, in
 *      which case the caller goes on without the lock.
 */
function holdUpgradeLock()
{
    if (!file_exists(C\TEMP_DIR)) {
        mkdir(C\TEMP_DIR, 0777, true);
    }
    $lock = fopen(C\TEMP_DIR . "/" . C\UPGRADE_LOCK_FILE, "c");
    $give_up = time() + C\UPGRADE_WAIT;
    while ($lock !== false && !flock($lock, LOCK_EX | LOCK_NB)) {
        if (time() >= $give_up) {
            A\webExit("This site is being upgraded. Please try again in " .
                "a minute.");
        }
        sleep(1);
    }
    return $lock;
}
/**
 * upgradeDatabaseWorkDirectory runs the migrations that bring a site's
 * database up to the version of the Yioop code that is running. A request
 * calls it when upgradeDatabaseWorkDirectoryCheck finds the database
 * older than the code. Only one request runs the migrations at a time:
 * the others wait for it, up to UPGRADE_WAIT seconds, and then find the
 * database current and run nothing.
 */
function upgradeDatabaseWorkDirectory()
{
    $lock = holdUpgradeLock();
    $model = new M\Model();
    $sql = "SELECT ID FROM VERSION";
    set_error_handler(null);
    $result = @$model->db->execute($sql);
    if ($result !== false) {
        $row = $model->db->fetchArray($result);
        if (!empty($row['ID'])) {
            $current_version = min($row['ID'], C\DATABASE_VERSION);
        } else if (!empty($row['id'])) {
            $current_version = min($row['id'], C\DATABASE_VERSION);
        } else {
            $current_version = 1;
        }
    } else {
        A\webExit(); // maybe someone else has locked DB, so bail
    }
    restore_error_handler();
    $result = null; //don't lock db if sqlite
    if ($current_version >= C\DATABASE_VERSION) {
        return;
    }
    $versions = range(1, C\DATABASE_VERSION);
    $key = array_search($current_version, $versions);
    $versions = array_slice($versions, $key + 1);
    foreach ($versions as $version) {
        $upgrade_db = C\NS_LIB . "upgradeDatabaseVersion$version";
        if (function_exists($upgrade_db)) {
            $upgrade_db($model->db);
        }
    }
    updateVersionNumber($model->db, C\DATABASE_VERSION);
}

/**
 * updateVersionNumber writes a new version number into the database's
 * VERSION table, which is the last step of a migration
 * @param object $db datasource for Yioop database
 * @param int $number the new database number
 */
function updateVersionNumber(&$db, $number)
{
    $db->execute("DELETE FROM VERSION");
    $db->execute("INSERT INTO VERSION VALUES ($number)");
}
/**
 * getWikiHelpPages reads the help articles out of the database and gives
 * back the array of pages, which the page generator writes into
 * PublicHelpPages.php.
 *
 * @return array|bool list of [PAGE_NAME, PAGE] entries pulled from the
 *      packaged default-help database; false when the default database
 *      cannot be opened
 */
function getWikiHelpPages()
{
    $help_pages = [];
    $default_dbm = new D\Sqlite3Manager();
    $default_dbm->connect("", "", "", C\BASE_DIR . "/data/public_default.db");
    if (!$default_dbm) {
        return false;
    }
    $group_model = new M\GroupModel(C\p('DB_NAME'), true);
    $wiki_model = new M\WikiModel(C\p('DB_NAME'), true);
    $wiki_model = new M\WikiModel(C\p('DB_NAME'), true);
    $group_model->db = $default_dbm;
    $page_list = $wiki_model->getPageList(
        C\HELP_GROUP_ID, "en-US", '', 0, 200);
    foreach ($page_list[1] as $page) {
        if (isset($page['TITLE'])) {
            $page_info = $wiki_model->getPageInfoByName(
                C\HELP_GROUP_ID, $page['TITLE'], "en-US", "api");
            $page_content = str_replace("&", "&", $page_info['PAGE']);
            $page_content = html_entity_decode($page_content, ENT_QUOTES,
                "UTF-8");
            $help_pages[$page['TITLE']] = $page_content;
        }
    }
    return $help_pages;
}
/**
 * addActivityAtId inserts a new activity into the database at a given
 * activity id, together with the translation rows that name it
 *
 * Inserting at an ID rather than at the end is useful since activities are
 * displayed in admin panel in order of increasing id.
 *
 * @param resource &$db database handle where Yioop database stored
 * @param string $string_id message identifier to give translations for
 *     for activity
 * @param string  $method_name admin_controller method to be called to perform
 *      this activity
 * @param int $activity_id the id location at which to create this activity
 *     activity at and below this location will be shifted down by 1.
 */
function addActivityAtId(&$db, $string_id, $method_name, $activity_id)
{
    $db->execute("UPDATE ACTIVITY SET ACTIVITY_ID = ACTIVITY_ID + 1 WHERE ".
        "ACTIVITY_ID >= ?", [$activity_id]);
    $sql = "SELECT * FROM ACTIVITY WHERE ACTIVITY_ID >= ?
        ORDER BY ACTIVITY_ID DESC";
    $result = $db->execute($sql, [$activity_id]);
    while ($row = $db->fetchArray($result)) {
        $db->execute("INSERT INTO ACTIVITY VALUES (?, ?, ?)",
            [($row['ACTIVITY_ID'] + 1), $row['TRANSLATION_ID'],
            $row['METHOD_NAME']]);
        $db->execute("DELETE FROM ACTIVITY WHERE ACTIVITY_ID = ?",
            [$row['ACTIVITY_ID']]);
    }
    if (!in_array($method_name, ["manageAdvertisements", "manageCredits"])) {
        $db->execute("UPDATE ROLE_ACTIVITY SET ACTIVITY_ID = ACTIVITY_ID + 1 ".
            "WHERE ACTIVITY_ID >= ?", [$activity_id]);
        //give root account permissions on the activity.
        $db->execute("INSERT INTO ROLE_ACTIVITY VALUES (1, ?, 'all')",
            [$activity_id]);
    }
    $sql = "SELECT COUNT(*) AS NUM FROM TRANSLATION";
    $result = $db->execute($sql);
    if (!$result || !($row = $db->fetchArray($result))) {
        echo "Upgrade activity error";
        exit();
    }
    //some search id start at 1000, so +1001 ensures we steer clear of them
    $translation_id = $row['NUM'] + 1001;
    $db->execute("INSERT INTO ACTIVITY VALUES (?, ?, ?)",
        [$activity_id, $translation_id, $method_name]);
    $db->execute("INSERT INTO TRANSLATION VALUES (?, ?)",
        [$translation_id, $string_id]);
}
/**
 * updateTranslationForStringId adds or replaces a translation of a
 * database message string for a given
 * IANA locale tag.
 *
 * @param resource &$db database handle where Yioop database stored
 * @param string $string_id message identifier to give translation for
 * @param string $locale_tag  the IANA language tag to update the strings of
 * @param string $translation the translation for $string_id in the language
 *     $locale_tag
 */
function updateTranslationForStringId(&$db, $string_id, $locale_tag,
    $translation)
{
    $sql = "SELECT LOCALE_ID FROM LOCALE ".
        "WHERE LOCALE_TAG = ? " . $db->limitOffset(1);
    $result = $db->execute($sql, [$locale_tag]);
    $row = $result ? $db->fetchArray($result) : false;
    $locale_id = $row['LOCALE_ID'] ?? null;
    $sql = "SELECT TRANSLATION_ID FROM TRANSLATION ".
        "WHERE IDENTIFIER_STRING = ? " . $db->limitOffset(1);
    $result = $db->execute($sql, [$string_id]);
    $row = $result ? $db->fetchArray($result) : false;
    $translate_id = $row['TRANSLATION_ID'] ?? null;
    /* a site may not have the locale, such as one added to Yioop after
       the site was made, and then there is nothing to translate into */
    if ($locale_id === null || $translate_id === null) {
        return;
    }
    $sql = "DELETE FROM TRANSLATION_LOCALE ".
        "WHERE TRANSLATION_ID =? AND ".
        "LOCALE_ID = ?";
    $result = $db->execute($sql, [$translate_id, $locale_id]);
    $sql = "INSERT INTO TRANSLATION_LOCALE VALUES (?, ?, ?)";
    $result = $db->execute($sql, [$translate_id, $locale_id, $translation]);
}