<?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]);
}