<?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 Mallika Perepa, Chris Pollett
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\models;

use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\av_processing\AudioConverter;
use seekquarry\yioop\library\av_processing\VideoExtractor;
use seekquarry\yioop\library\MediaConstants;
use seekquarry\yioop\library\version_control\VersionManager;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\library\processors\TextProcessor;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\library\processors\EpubProcessor;
use seekquarry\yioop\library\processors\PdfProcessor;
use seekquarry\yioop\library\processors\VideoProcessor;
use seekquarry\yioop\models\ImpressionModel;
use seekquarry\yioop\library\wiki as LW;

/**
 * WikiModel stores the wiki pages of a group in the database. A wiki
 * page is a page the members of a group write together.
 *
 * It writes a new version of a page and keeps the older versions, so a
 * reader can see what changed. It stores the settings a writer chose
 * for a page, such as its type and its border. It stores the files a
 * writer puts beside a page, and the folders those files sit in. It
 * also stores git issues, which are wiki pages of a group that stands
 * for a code repository.
 *
 * This model extends GroupModel, which stores the group a page belongs
 * to and the key that encrypts a private group's pages.
 *
 * SocialComponent uses this model to draw and save a wiki page.
 * WikiElement draws what it reads. Createdb uses it to write the pages
 * a new site starts with.
 *
 * @author Chris Pollett
 */
class WikiModel extends GroupModel
{
    /**
     * deleteHeldGitIssue removes a report from the queue waiting for an editor,
     * whether it has been accepted as an issue or thrown away. There is no
     * undoing it.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param int $number which waiting report
     * @param string $locale_tag language the page is written for
     * @return bool whether a page was removed
     */
    public function deleteHeldGitIssue($group_id, $page_name, $number,
        $locale_tag)
    {
        return $this->deleteGroupPage($group_id, $page_name .
            C\GIT_ISSUE_HELD_SEPARATOR . $number, $locale_tag);
    }
    /**
     * deleteGitIssueBan lets a reporter back in to reporting against a
     * repository by removing the page that shut them out.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $token the token standing for the reporter
     * @param string $locale_tag language the page is written for
     * @return bool whether a page was removed
     */
    public function deleteGitIssueBan($group_id, $page_name, $token,
        $locale_tag)
    {
        return $this->deleteGroupPage($group_id, $page_name .
            C\GIT_ISSUE_BAN_SEPARATOR . $token, $locale_tag);
    }
    /**
     * setPageName used to add a wiki page revision by a given user to a wiki
     * page of a given name in a given group viewing the group under a given
     * language. If the page does not exist yet it, and its corresponding
     * discussion thread is created. Two pages are used for storage GROUP_PAGE
     * which contains a parsed to html version of the most recent revision of a
     * wiki page and GROUP_PAGE_HISTORY which contains non-parsed versions of
     * all revisions
     * @param int $user_id identifier of who is adding this revision
     * @param int $group_id which group the wiki page revision if being done in
     * @param string $page_name title of page being revised
     * @param string $page wiki page with potential wiki mark up containing the
     *     revision
     * @param string $locale_tag locale we are adding the revision to
     * @param string $edit_comment user's reason for making the revision
     * @param string $thread_title if this is the first revision, then this
     *     should contain the title for the discussion thread about the revision
     * @param string $thread_description if this is the first revision, then
     *     this should be the body of the first post in discussion thread
     * @param string $base_address default url to be used in links on wiki page
     *     that use short syntax
     * @param bool $add_relationship_data whether to add link relationship data
     *     for this wiki page or not (default add, typically won't add for
     *     knowledge wiki pages for search results generated by
     *     configs/TokenTool.php)
     * @param int $pubdate if not >=0 then the tiemstate of when the post was
     *     published; otherwise current time will be used
     * @param int $input_timestamp optional UNIX timestamp to use for impression
     *     tracking; if set to -1 or omitted, the current time will be used
     * @return int $page_id id of added or updated page
     */
    public function setPageName($user_id, $group_id, $page_name, $page,
        $locale_tag, $edit_comment, $thread_title, $thread_description,
        $base_address = "", $add_relationship_data = true, $pubdate = -1,
        $input_timestamp = -1)
    {
        $db = $this->db;
        $pubdate = ($pubdate == -1) ? time() : $pubdate;
        $parser = new WikiParser($base_address);
        $end_head = WikiParser::END_HEAD_VARS;
        if ($add_relationship_data) {
            $links_relationships = $parser->fetchLinks($page, $page_name);
        }
        $is_form = false;
        if ($this->getPageType($page) == 'share') {
            $parsed_page = $page;
        } else {
            if (strstr($page, '{{submit|') !== false) {
                $is_form = true;
            }
            if (!str_contains($page, $end_head)) {
                $page = WikiParser::makeWikiPageHead() . $end_head . $page;
            }
            $render_engine = $this->getRenderEngine($group_id);
            $parsed_page = $parser->parse($page, render_engine: $render_engine);
            if ($is_form && strstr($page, '<form ') !== false) {
                return null;
            }
        }
        if ($page_id = $this->getPageId($group_id, $page_name, $locale_tag)) {
 /* can only add and use resources for a page that exists */
            $parsed_page = $this->insertResourcesParsePage($group_id, $page_id,
                $locale_tag, $parsed_page);
            if ($is_form) {
                $has_answer_field =
                    str_contains($parsed_page, '[{keyword-captcha');
                $form_proof = WikiParser::proofOfWorkFormFields(
                    !$has_answer_field);
                $parsed_page = str_replace($end_head, $end_head .
                    "\n<form method='post' >\n<input type='hidden' name='" .
                    C\p('CSRF_TOKEN') . "' value='[{just-token}]' >" .
                    "<input type='hidden' name='CSV_FORM_HASH' ".
                    "value='[{form-hash}]' >" . $form_proof, $parsed_page);
                $parsed_page .= "\n</form>\n";
                $page_body = explode($end_head, $parsed_page, 2)[1];
                $parsed_page = preg_replace("/\[{form\-hash}\]/",
                    "[{form-hash". hash("sha256", $page_body) . "}]",
                    $parsed_page);
            }
            $sql = "UPDATE GROUP_PAGE SET PAGE=?, LAST_MODIFIED=? WHERE ID = ?";
            $result = $db->execute($sql, [$parsed_page, $pubdate, $page_id]);
        } else {
            $feed_model = new FeedModel($this->db_name, false);
            $feed_model->db = $db;
            $discuss_thread = $feed_model->addGroupItem(0, $group_id,
                $user_id, $thread_title,
                $thread_description . " " . date("r", $pubdate),
                C\WIKI_GROUP_ITEM, $pubdate, input_timestamp:
                $input_timestamp);
            $sql = "INSERT INTO GROUP_PAGE (DISCUSS_THREAD, GROUP_ID, TITLE,
                PAGE, LOCALE_TAG, LAST_MODIFIED) VALUES (?, ?, ?, ?, ?, ?)";
            $result = $db->execute($sql, [$discuss_thread, $group_id,
                $page_name, $parsed_page, $locale_tag, $pubdate]);
            $page_id = $db->insertID("GROUP_PAGE");
            ImpressionModel::initWithDb($user_id, $page_id, C\WIKI_IMPRESSION,
                $db, $input_timestamp);
            ImpressionModel::initWithDb(C\PUBLIC_USER_ID, $page_id,
                C\WIKI_IMPRESSION, $db, $input_timestamp);
        }
        $this->insertPageHistory($db, $page_id, $user_id, $group_id,
            $page_name, $page, $locale_tag, $pubdate, $edit_comment);
        if (!$add_relationship_data) {
            return $page_id;
        }
        $sql = "DELETE FROM GROUP_PAGE_LINK WHERE FROM_ID = ?";
        $db->execute($sql, [$page_id]);
        $template_prefix = "template:";
        if (substr($page_name, 0, strlen($template_prefix)) ==
            $template_prefix) {
            $sql = "INSERT INTO GROUP_PAGE_LINK (LINK_TYPE_ID, FROM_ID, TO_ID)".
                " VALUES (?, ?, ?)";
            $db->execute($sql, [C\WIKI_TEMPLATE_LINK, $page_id, $group_id]);
        }
        $sql = "INSERT INTO GROUP_PAGE_LINK (LINK_TYPE_ID, FROM_ID, TO_ID) ".
            "SELECT LINK_TYPE_ID, FROM_ID, ?  FROM GROUP_PAGE_PRE_LINK ".
            "WHERE TO_GROUP_ID = ? AND TO_PAGE_NAME = ?";
        $db->execute($sql, [$page_id, $group_id, $page_name]);
        $sql = "DELETE FROM GROUP_PAGE_PRE_LINK WHERE TO_GROUP_ID = ? AND " .
            "TO_PAGE_NAME = ?";
        $db->execute($sql, [$group_id, $page_name]);
        $sql = "DELETE FROM GROUP_PAGE_PRE_LINK WHERE FROM_ID = ?";
        $db->execute($sql, [$page_id]);
        $link_sql = "INSERT INTO GROUP_PAGE_LINK ".
            "(LINK_TYPE_ID, FROM_ID, TO_ID) VALUES (?, ?, ?)";
        $pre_link_sql = "INSERT INTO GROUP_PAGE_PRE_LINK ".
            "(LINK_TYPE_ID, FROM_ID, TO_GROUP_ID, TO_PAGE_NAME) ".
            " VALUES (?, ?, ?, ?)";
        foreach ($links_relationships as $links_relationship) {
 /* extract link and relation type from $links_relationship */
            list($link_page_name, $relationship_type) = explode('|',
                $links_relationship);
            if (!$relationship_type) {
                $relationship_id = C\WIKI_STANDARD_LINK;
            } else if (!($relationship_id = $this->getRelationshipId(
                $relationship_type))) {
                $sql = "INSERT INTO PAGE_RELATIONSHIP (NAME) VALUES (?)";
                $db->execute($sql, [$relationship_type]);
                $relationship_id = $this->getRelationshipId(
                    $relationship_type);
            }
 /* get page id */
            $linked_page_id = $this->getPageId($group_id, $link_page_name,
                $locale_tag);
 /* insert into GROUP_PAGE_LINK values of parent and child links */
            if (!$linked_page_id) {
                $db->execute($pre_link_sql, [$relationship_id, $page_id,
                    $group_id, $link_page_name]);
            } else {
                $db->execute($link_sql, [$relationship_id, $page_id,
                    $linked_page_id]);
            }
        }
        return $page_id;
    }
    /**
     * getClipTransferPaths helper method to get file_paths for copying/moving
     * resources to a user's personal clipboard
     * @param int $user_id of user whose clip_folder need paths for
     * @param string $resource_name what to copy/move
     * @param int $group_id id of group the file resource belongs to
     * @param int $page_id id of page the file resource belongs to
     * @param string $sub_path path within the page resource folder to the
     *     folder that contains the resource to copy/move
     * @return mixed array [file_path, thumb_folder, clip_path,
     *     clip_thumb_folder, base_folder] for the source-and-destination pair,
     *     or false on any folder-resolution failure (no personal group, no
     *     clipboard page, missing clip folder)
     */
    private function getClipTransferPaths($user_id,
        $resource_name, $group_id, $page_id, $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
            $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder, ) = $folders;
        $file_path =  "$folder/$resource_name";
        if (($clip_group_id = $this->getPersonalGroupId($user_id)) < 0) {
            return false;
        }
        if (!($clip_page_id = $this->getPageId($clip_group_id,
            C\CLIPBOARD_PAGE_NAME, C\p('DEFAULT_LOCALE')) ) ) {
            return false;
        }
        $folders = $this->getGroupPageResourcesFolders($clip_group_id,
            $clip_page_id);
        list($clip_folder, $clip_thumb_folder, $base_clip_folder, ) = $folders;
        if (!is_dir($clip_folder)) {
            return false;
        }
        $clip_path = "$clip_folder/$resource_name";
        return [$file_path, $thumb_folder, $clip_path, $clip_thumb_folder,
            $base_folder];
    }
    /**
     * countGroupWikiPages returns how many wiki pages a group has.
     * @param int $group_id group to count wiki pages for
     * @return int number of wiki pages belonging to $group_id
     */
    public function countGroupWikiPages($group_id)
    {
        $db = $this->db;
        $sql = "SELECT COUNT(*) AS NUM FROM GROUP_PAGE " .
            "WHERE GROUP_ID = ?";
        $result = $db->execute($sql, [$group_id]);
        $row = ($result) ? $db->fetchArray($result) : ['NUM' => 0];
        return $row['NUM'];
    }
    /**
     * deleteGroupPage deletes a wiki page from a group from the database
     * @param string $group_id id of the group to delete
     * @param string $page_name name of wiki page to delete
     * @param string $locale_tag locale of page to delete
     * @return bool whether page was deleted
     */
    public function deleteGroupPage($group_id, $page_name, $locale_tag)
    {
        $db = $this->db;
        $page_id = $this->getPageId($group_id, $page_name, $locale_tag);
        if (!$page_id) {
            return false;
        }
        list($folder, $thumb_folder, ) =
            $this->getGroupPageResourcesFolders($group_id, $page_id, "", false,
            false);
        if ($folder && file_exists($folder)) {
            $db->unlinkRecursive($folder);
        }
        if ($thumb_folder && file_exists($thumb_folder)) {
            $db->unlinkRecursive($thumb_folder);
        }
        $params = [$page_id];
        $sql = "DELETE FROM GROUP_PAGE WHERE ID=?";
        $db->execute($sql, $params);
        $sql = "DELETE FROM GROUP_PAGE_HISTORY WHERE PAGE_ID=?";
        $db->execute($sql, $params);
        return true;
    }
    /**
     * insertPageHistory writes one revision row into the page-history table for
     * a wiki page, first removing any row that already shares this page and
     * timestamp. The history is keyed by page and whole-second timestamp, so
     * two saves of the same page within the same second would otherwise collide
     * on the primary key. Removing the earlier row lets the later save win,
     * which is what happens when a resource upload re-saves the page a moment
     * after the first save in the same second.
     * @param object $db open database connection to write through
     * @param int $page_id id of the wiki page this revision belongs to
     * @param int $user_id id of the user credited as the editor
     * @param int $group_id id of the group that owns the page
     * @param string $page_name title of the page
     * @param string $page the page source stored for this revision
     * @param string $locale_tag locale the revision was written in
     * @param int $pubdate whole-second timestamp of the revision
     * @param string $edit_comment short reason shown in the history list
     * @return void Nothing is handed back.
     */
    public function insertPageHistory($db, $page_id, $user_id, $group_id,
        $page_name, $page, $locale_tag, $pubdate, $edit_comment)
    {
        $sql = "DELETE FROM GROUP_PAGE_HISTORY WHERE PAGE_ID = ? AND
            PUBDATE = ?";
        $db->execute($sql, [$page_id, $pubdate]);
        $sql = "INSERT INTO GROUP_PAGE_HISTORY (PAGE_ID, EDITOR_ID,
            GROUP_ID, TITLE, PAGE, LOCALE_TAG, PUBDATE, EDIT_COMMENT)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
        $db->execute($sql, [$page_id, $user_id, $group_id, $page_name,
            $page, $locale_tag, $pubdate, $edit_comment]);
    }
    /**
     * setPageName used to add a wiki page revision by a given user to a wiki
     * page of a given name in a given group viewing the group under a given
     * language. If the page does not exist yet it, and its corresponding
     * discussion thread is created. Two pages are used for storage GROUP_PAGE
     * which contains a parsed to html version of the most recent revision of a
     * wiki page and GROUP_PAGE_HISTORY which contains non-parsed versions of
     * all revisions
     * @param int $user_id identifier of who is adding this revision
     * @param int $group_id which group the wiki page revision if being done in
     * @param string $page_name title of page being revised
     * @param string $page wiki page with potential wiki mark up containing the
     *     revision
     * @param string $locale_tag locale we are adding the revision to
     * @param string $edit_comment user's reason for making the revision
     * @param string $thread_title if this is the first revision, then this
     *     should contain the title for the discussion thread about the revision
     * @param string $thread_description if this is the first revision, then
     *     this should be the body of the first post in discussion thread
     * @param string $base_address default url to be used in links on wiki page
     *     that use short syntax
     * @param bool $add_relationship_data whether to add link relationship data
     *     for this wiki page or not (default add, typically won't add for
     *     knowledge wiki pages for search results generated by
     *     configs/TokenTool.php)
     * @param int $pubdate if not >=0 then the tiemstate of when the post was
     *     published; otherwise current time will be used
     * @param int $input_timestamp optional UNIX timestamp to use for impression
     *     tracking; if set to -1 or omitted, the current time will be used
     * @return int $page_id id of added or updated page
     */
        /**
     * getPageType returns the page type of an unparsed wiki page
     * @param string $unparsed_page an unparsed wiki page (i.e., so has the
     *     configuration parameters in its head)
     * @return string|bool returns the pages type as a string or false if this
     *     page head variable is not set
     */
    public function getPageType($unparsed_page)
    {
        if (preg_match('/^.*page_type\s*\=\s*(\w+).*END\_HEAD\_VARS/s',
            $unparsed_page, $matches)) {
            return $matches[1];
        }
        return false;
    }
    /**
     * getPageId looks up the page_id of a wiki page based on the group it
     * belongs to, its title, and the language it is in (these three things
     * together should uniquely fix a page).
     * @param int $group_id group identifier of group wiki page belongs to
     * @param string $page_name title of wiki page to look up
     * @param string $locale_tag IANA language tag of page to lookup
     * @return mixed $page_id of page if exists, false otherwise
     */
    public function getPageId($group_id, $page_name, $locale_tag)
    {
        $db = $this->db;
        $sql = "SELECT ID FROM GROUP_PAGE WHERE GROUP_ID = ?
            AND TITLE=? AND LOCALE_TAG= ?";
        $result = $db->execute($sql, [$group_id, $page_name, $locale_tag]);
        if (!$result) {
            return false;
        }
        $row = $db->fetchArray($result);
        if ($row) {
            return $row["ID"];
        }
        return false;
    }
    /**
     * pagesLinkedWithRelationship gets all the pages that are linked to a
     * particular wiki page by providing a particular relationship type.
     * @param int $page_id identifier for the current page
     * @param int $group_id group that wiki page belongs
     * @param string $page_name name of the current page (in case page didn't
     *     exist before)
     * @param string $relationship the type of relationship linking the wiki
     *     pages
     * @param string $limit first row we want from the result set
     * @param string $num number of rows we want starting from the first row in
     *     the result set
     * @return two arrays of elements which represent all pages linked to and
     *     from the given wiki page with a particular relationship
     */
    public function pagesLinkedWithRelationship($page_id, $group_id,
        $page_name, $relationship, $limit, $num)
    {
        $db = $this->db;
 /* get data from group pre link and insert to group link */
        $sql = "INSERT INTO GROUP_PAGE_LINK (LINK_TYPE_ID, FROM_ID, TO_ID) ".
            "SELECT LINK_TYPE_ID, FROM_ID, ?  FROM GROUP_PAGE_PRE_LINK ".
            "WHERE TO_GROUP_ID = ? AND TO_PAGE_NAME = ?";
        $db->execute($sql, [$page_id, $group_id, $page_name]);
        $sql = "DELETE FROM GROUP_PAGE_PRE_LINK WHERE TO_GROUP_ID = ? AND " .
            "TO_PAGE_NAME = ?";
        $db->execute($sql, [$group_id, $page_name]);
        $sql = "DELETE FROM GROUP_PAGE_PRE_LINK WHERE FROM_ID = ?";
        $db->execute($sql, [$page_id]);
 /* get the count of pages that link to the given page */
        $sql = "SELECT COUNT(*) AS NUM
            FROM GROUP_PAGE_LINK L, PAGE_RELATIONSHIP P
            WHERE L.TO_ID = ? AND P.NAME = ? AND (L.LINK_TYPE_ID = P.ID)";
        $result = $db->execute($sql, [$page_id, $relationship]);
        if ($result) {
            $row = $db->fetchArray($result);
            $total_to_pages = $row['NUM'];
        }
 /* get the count of pages that link from the given page */
        $sql = "SELECT COUNT(*) AS NUM
            FROM GROUP_PAGE_LINK L, PAGE_RELATIONSHIP P
            WHERE L.FROM_ID = ? AND P.NAME = ? AND (L.LINK_TYPE_ID = P.ID)";
        $result = $db->execute($sql, [$page_id, $relationship]);
        if ($result) {
            $row = $db->fetchArray($result);
            $total_from_pages = $row['NUM'];
        }
 /* get the array of all pages linking to this page */
        $pages_that_link_to = [];
        $sql = "SELECT G.TITLE AS PAGES_LINKING_TO, G.ID AS PAGE_ID
            FROM GROUP_PAGE_LINK L, GROUP_PAGE G, PAGE_RELATIONSHIP P
            WHERE L.TO_ID = ? AND P.NAME = ? AND (L.FROM_ID = G.ID)
            AND (L.LINK_TYPE_ID = P.ID)"
            .$db->limitOffset($limit, $num);
        $result = $db->execute($sql, [$page_id, $relationship]);
        if ($result) {
            while ($tmp = $db->fetchArray($result)) {
                $pages_that_link_to[] = $tmp;
            }
        }
 /* get the array of all pages that link from */
        $pages_that_link_from = [];
        $sql = "SELECT G.TITLE AS PAGES_LINKING_FROM, G.ID AS PAGE_ID
            FROM GROUP_PAGE_LINK L, GROUP_PAGE G, PAGE_RELATIONSHIP P
            WHERE L.FROM_ID = ? AND P.NAME = ? AND (L.TO_ID = G.ID)
            AND (L.LINK_TYPE_ID = P.ID)"
            .$db->limitOffset($limit, $num);
        $result = $db->execute($sql, [$page_id, $relationship]);
        if ($result) {
            while ( $tmp = $db->fetchArray($result)) {
                $pages_that_link_from[] = $tmp;
            }
        }
        return [$total_to_pages, $pages_that_link_to, $total_from_pages,
            $pages_that_link_from];
    }
    /**
     * getRelationshipsToFromPage gets all the relationship types between this
     * particular wiki page and all other pages that it is linked to.
     * @param int $page_id identifier for the current page
     * @param string $limit first row we want from the result set
     * @param string $num number of rows we want starting from the first row in
     *     the result set
     * @return array of relationship types which represent all relationships
     *     between the given wiki page and all other linked wiki pages
     */
    public function getRelationshipsToFromPage($page_id, $limit, $num)
    {
        $total = $this->countPageRelationships($page_id);
        $db = $this->db;
        $i = 0;
        $relationships = [];
        $sql = "SELECT DISTINCT R.NAME AS RELATIONSHIP_TYPE
            FROM PAGE_RELATIONSHIP R, GROUP_PAGE_LINK G
            where (G.FROM_ID = ? OR G.TO_ID = ?) AND
            (R.ID = G.LINK_TYPE_ID)".$db->limitOffset($limit, $num);
        $result = $db->execute($sql, [$page_id, $page_id]);
        if ($result) {
            while ($relationships[$i] = $db->fetchArray($result)) {
                $i++;
            }
            unset($relationships[$i]);
        }
        return [$total, $relationships];
    }
    /**
     * countPageRelationships gets if there is any page related to this
     * particular wiki page.
     * @param int $page_id identifier for the current page
     * @return int distinct relationship-type count for the given page (0 when
     *     $page_id is falsy or the page has no relationships)
     */
    public function countPageRelationships($page_id)
    {
        if (!$page_id) {
            return 0;
        }
        $db = $this->db;
        $sql = "SELECT COUNT(DISTINCT LINK_TYPE_ID) AS NUM FROM GROUP_PAGE_LINK
            WHERE TO_ID = ? OR FROM_ID = ?";
        $result = $db->execute($sql, [$page_id, $page_id]);
        if ($result) {
            $row = $db->fetchArray($result);
            $total = $row['NUM'];
        }
        return $total;
    }
    /**
     * getPageInfoByPageId returns the group_id, language, and page name of a
     * wiki page corresponding to $page_id
     * @param int $page_id to look up page info for
     * @return array (group_id, language, and page name) of that wiki page
     */
    public function getPageInfoByPageId($page_id)
    {
        $db = $this->db;
        $sql = "SELECT GROUP_ID, LOCALE_TAG, TITLE AS PAGE_NAME,
            DISCUSS_THREAD, LAST_MODIFIED FROM GROUP_PAGE WHERE ID = ?";
        $result = $db->execute($sql, [$page_id]);
        if (!$result) {
            return false;
        }
        $row = $db->fetchArray($result);
        if (!$row) {
            return false;
        }
        return $row;
    }
    /**
     * getPageBodyByPageId gives the source of a wiki page, the text a writer
     * typed, from its page id. The record of a page holds what it is called and
     * when it changed, not what it says, so anything reading the marks a page
     * carries has to ask for the text itself.
     * @param int $page_id id of the wiki page to read
     * @return string the page's source, or the empty string where there is no
     *     such page
     */
    public function getPageBodyByPageId($page_id)
    {
        $db = $this->db;
        $sql = "SELECT PAGE FROM GROUP_PAGE WHERE ID = ?";
        $result = $db->execute($sql, [$page_id]);
        if (!$result) {
            return "";
        }
        $row = $db->fetchArray($result);
        return $row["PAGE"] ?? "";
    }
    /**
     * getPageTypeByPageId looks up the kind of a wiki page, such as standard or
     * git_repository, from its page id by reading the page's head.
     * @param int $page_id id of the wiki page to look up
     * @return mixed the page type string, or false if the page was not found
     */
    public function getPageTypeByPageId($page_id)
    {
        $db = $this->db;
        $sql = "SELECT PAGE FROM GROUP_PAGE WHERE ID = ?";
        $result = $db->execute($sql, [$page_id]);
        if (!$result) {
            return false;
        }
        $row = $db->fetchArray($result);
        if (!$row) {
            return false;
        }
        return $this->getPageType($row["PAGE"]);
    }
    /**
     * versionManagerForPage makes a VersionManager for a wiki page's resource
     * folder, turning its own versioning off when the page is a git repository
     * page. Git keeps that page's history itself, so a second copy under an
     * archive folder would only duplicate it.
     * @param string $folder resource folder the manager will look after
     * @param int $page_id id of the page whose resources are managed
     * @return VersionManager the manager for the folder
     */
    private function versionManagerForPage($folder, $page_id)
    {
        $versioning =
            ($this->getPageTypeByPageId($page_id) != "git_repository");
        return new VersionManager($folder, versioning: $versioning);
    }
    /**
     * getHistoryPage returns an historical revision of a wiki page
     * @param int $page_id identifier of wiki page want revision for
     * @param int $pubdate timestamp of revision desired
     * @return array (id, non-parsed wiki page, page_name, group id, locale_tag,
     *     discussion thread id) of page revision
     */
    public function getHistoryPage($page_id, $pubdate)
    {
        $db = $this->db;
        $sql = "SELECT HP.PAGE_ID AS ID, HP.PAGE AS PAGE, HP.TITLE AS PAGE_NAME,
            HP.GROUP_ID AS GROUP_ID, HP.LOCALE_TAG AS LOCALE_TAG,
            GP.DISCUSS_THREAD AS DISCUSS_THREAD FROM GROUP_PAGE GP,
            GROUP_PAGE_HISTORY HP WHERE HP.PAGE_ID = ?
            AND HP.PUBDATE=? AND HP.PAGE_ID=GP.ID";
        $result = $db->execute($sql, [$page_id, $pubdate]);
        if (!$result) {
            return false;
        }
        $row = $db->fetchArray($result);
        if (!isset($row["PAGE"])) {
            return false;
        }
        return $row;
    }
    /**
     * getPageHistoryList returns a list of revision history info for a wiki
     * page.
     * @param int $page_id identifier for page want revision history of
     * @param int $limit first row we want from the result set
     * @param int $num number of rows we want starting from the first row in the
     *     result set
     * @return array elements of which are array with the revision date
     *     (PUBDATE), user name, page length, edit reason for the wiki pages
     *     revision
     */
    public function getPageHistoryList($page_id, $limit, $num)
    {
        $db = $this->db;
        $sql = "SELECT COUNT(*) AS TOTAL, MAX(TITLE) AS PAGE_NAME
            FROM GROUP_PAGE_HISTORY
            WHERE PAGE_ID = ?";
        $page_name = "";
        $result = $db->execute($sql, [$page_id]);
        if ($result) {
            $row = $db->fetchArray($result);
            $total = (!empty($row)) ? $row["TOTAL"] : 0;
            $page_name = (!empty($row)) ? $row["PAGE_NAME"] : "";
        }
        $pages = [];
        if ($total > 0) {
            $sql = "SELECT H.PUBDATE AS PUBDATE, U.USER_NAME AS USER_NAME,
                LENGTH(H.PAGE) AS PAGE_LEN,
                H.EDIT_COMMENT AS EDIT_REASON FROM GROUP_PAGE_HISTORY H, USERS U
                WHERE H.PAGE_ID = ? AND
                U.USER_ID= H.EDITOR_ID ORDER BY PUBDATE DESC ".
                $db->limitOffset($limit, $num);
            $result = $db->execute($sql, [$page_id]);
            if ($result) {
                while ($row = $db->fetchArray($result)) {
                    $pages[] = $row;
                }
            }
        }
        return [$total, $page_name, $pages];
    }
    /**
     * getGroupIdPageIdSubPathFromName given the Wiki name in the format
     * GroupName@PageName/sub_path/some_file returns array [group_id, page_id,
     * sub_path, some_file] for the given resource. If one of the components is
     * missing in the above, does its best guess for the value
     * @param string $complete_group_page_name formatted as described in summary
     * @param string $locale_tag language of wiki page
     * @return array [group_id, page_id, sub_path, some_file]
     */
    public function getGroupIdPageIdSubPathFromName(
        $complete_group_page_name, $locale_tag = C\DEFAULT_LOCALE)
    {
        $name_parts = explode("@", $complete_group_page_name, 2);
        if (count($name_parts) == 1) {
            $group_id = C\PUBLIC_GROUP_ID;
            $name_path = $complete_group_page_name;
        } else {
            $group_id = $this->getGroupId($name_parts[0]);
            $name_path = $name_parts[1];
        }
        $name_path_parts = explode("/", $name_path, 2);
        if (count($name_path_parts) == 1) {
            list($page_name, $sub_path) = [$name_path, ""];
        } else {
            list($page_name, $sub_path) = $name_path_parts;
        }
        $trim_flag = false;
        if (str_contains($sub_path, "%") &&
            !str_contains($sub_path, ".")) {
            $sub_path = "$sub_path.t";
            $trim_flag = 2;
        }
        $path_parts = pathinfo($sub_path);
        if (empty($path_parts['extension'])) {
            $file_name = "";
        } else {
            $file_name = $path_parts['basename'];
            if ($trim_flag) {
                $file_name = substr($file_name, 0, -2);
            }
            $sub_path = $path_parts['dirname'];
        }
        $page_name = str_replace(" ", "_", trim($page_name));
        $page_id = $this->getPageId($group_id, $page_name, $locale_tag);
        return [$group_id, $page_id, $sub_path, $file_name];
    }
    /**
     * resourcePathBroken tells whether a page has a resource path (a redirect
     * to a folder the operator chose for its resources) that is set but cannot
     * be used because that folder does not exist and could not be created. Such
     * a page has nowhere valid to keep its resources, and the read view warns
     * about it rather than the resources silently going to, or coming from, the
     * page's default folder.
     * @param int $group_id group the page belongs to
     * @param int $page_id page to check
     * @return bool whether the page's resource path is set but unusable
     */
    public function resourcePathBroken($group_id, $page_id)
    {
        $default = $this->getGroupPageResourcesFolders($group_id, $page_id,
            "", false, false, false);
        if (!is_array($default) ||
            !file_exists($default[0] . "/redirect.txt")) {
            return false;
        }
        return $this->getGroupPageResourcesFolders($group_id, $page_id, "",
            false) === false;
    }
    /**
     * insertResourcesParsePage given a wiki page that has been parsed to html
     * except for wiki syntax related to resources, this method adds the html to
     * include these resources
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to parse resources for
     * @param string $locale_tag the locale of the parsed page.
     * @param string $parsed_page the parsed wiki page before resources added
     * @param string $csrf_token to prevent cross-site request forgery
     * @param string $controller name of controller (admin or group) that
     *     inserted urls should be for
     * @param bool $include_charts_and_spreadsheets when true, render
     *     chart/spreadsheet resources inline as well; when false (default) they
     *     are linked rather than embedded
     * @return string resulting html page
     */
    public function insertResourcesParsePage($group_id, $page_id, $locale_tag,
        $parsed_page, $csrf_token = "", $controller = 'admin',
        $include_charts_and_spreadsheets = false)
    {
        $default_folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id);
        $autoplay = "autoplay='autoplay'";
        if (str_starts_with($page_id, 'post')) {
            $autoplay = "";
        }
        if ($default_folders) {
            list($folder, $thumb_folder,) = $default_folders;
        } else {
            $folder = "";
            $thumb_folder = "";
        }
        /* A resource may carry no description at all. A recording made
           where the browser heard nothing has none, and demanding one
           left the whole marker standing in the message as plain text
           rather than becoming the recording it names. */
        if (!preg_match_all('/\(\(resource(\-?[a-z]+)?\:(.+?)\|(.*?)\)\)/ui',
            $parsed_page, $matches)) {
            return $parsed_page;
        }
        $num_matches = count($matches[0]);
        for ($i = 0; $i < $num_matches; $i++) {
            $match_string = $matches[0][$i];
            $resource_namespace_name = $matches[2][$i];
            if (empty($matches[1][$i])) {
                $resource_namespace_name = urldecode($resource_namespace_name);
            }
            /* a !verbose suffix asks for the download link and, for a
               spreadsheet, the histogram toggle; without it they are left
               off so a page can just show its data. It uses a symbol other
               than a colon so it does not clash with the namespace colon,
               and it is pulled off here before the namespace split and the
               rectangle parse below */
            $verbose = false;
            if (str_ends_with($resource_namespace_name, "!verbose")) {
                $verbose = true;
                $resource_namespace_name = substr($resource_namespace_name,
                    0, -strlen("!verbose"));
            }
            $namespace_parts = explode(":", $resource_namespace_name, 2);
            if (count($namespace_parts) > 1 && $matches[1][$i] != "-qr") {
                list($current_namespace, $resource_namespace_name) =
                    $namespace_parts;
                if (empty($current_namespace)) {
                    $current_page_id = $page_id;
                } else {
                    $current_page_id = $this->getPageId($group_id,
                        $current_namespace, $locale_tag);
                }
                if ($current_page_id === false || $current_page_id === null) {
                    continue;
                }
                $current_folders = $this->getGroupPageResourcesFolders(
                    $group_id, $current_page_id);
                if ($current_folders) {
                    list($current_folder, $current_thumb_folder,) =
                        $current_folders;
                    if (!$current_thumb_folder) {
                        continue;
                    }
                } else {
                    continue;
                }
            } else {
                $current_page_id = $page_id;
                $current_folder = $folder;
                $current_thumb_folder = $thumb_folder;
            }
            $sub_path = "";
            $chart_resource = (in_array($matches[1][$i], ["-bargraph",
                "-linegraph", "-pointgraph"])) ? true : false;
            $data_resource = ($matches[1][$i] == "-data") ? true : false;
            $nolink_resource = ($matches[1][$i] == "-nolink") ? true : false;
            $qr_resource = ($matches[1][$i] == "-qr") ? true : false;
            $thumb_resource = ($matches[1][$i] == "-thumb") ? true : false;
            $resource_description = $matches[3][$i];
            $resource_description_parts = explode("|", $matches[3][$i]);
            if (!empty($resource_description_parts[1])) {
                $resource_description = $resource_description_parts[1];
                $sub_path = $resource_description_parts[0];
            }
            $spreadsheet_rectangle = false;
            $rect_parts = explode("#", $resource_namespace_name);
            $data_chart = false;
            if (count($rect_parts) > 1) {
                if ($chart_resource) {
                    $num_rect_parts = count($rect_parts);
                    $data_chart = empty($rect_parts[0]) && $num_rect_parts > 2;
                    if ($data_chart || $num_rect_parts == 6) {
                        $resource_name = "";
                        if ($data_chart) {
                            $points = array_slice($rect_parts, 2);
                            $x_values = [];
                            $y_values = [];
                            foreach ($points as $point) {
                                preg_match("/^\s*\((.+)\,(.+)\)\s*$/",
                                    $point, $point_matches);
                                if (empty($point_matches[2])) {
                                    break;
                                }
                                $x_values[] = $point_matches[1];
                                $y_values[] = $point_matches[2];
                            }
                        } else {
                            list($resource_name, $chart_config, $x_start,
                                $x_end, $y_start, $y_end) = $rect_parts;
                        }
                        $chart_type = ($matches[1][$i] == '-bargraph') ?
                            "BarGraph" : (($matches[1][$i] == '-linegraph')
                            ? "LineGraph" : "PointGraph");
                        if (!empty($chart_config)) {
                            $chart_config = json_decode($chart_config, true);
                        } else {
                            $chart_config = [];
                        }
                        $chart_config['type'] = $chart_type;
                        $chart_config = json_encode($chart_config);
                    } else {
                        $resource_name = implode("#", $rect_parts);
                        $chart_resource = false;
                    }
                } else {
                    $spreadsheet_rectangle = $this->convertSpreadsheetRectangle(
                        array_slice($rect_parts, 2));
                    if ($spreadsheet_rectangle === false) {
                        $resource_name = implode("#", $rect_parts);
                    } else {
                        $resource_name = $rect_parts[0];
                        $sheet_config = ($rect_parts[1] == 'noheadings') ?
                            "{'headings': false}" : "{}";
                    }
                }
            }
            $resource_name = (isset($rect_parts[0])) ? $rect_parts[0] :
                $resource_namespace_name;
            $is_dir = false;
            if ($data_chart) {
                $mime_type = "text/csv";
                $resource_url = "";
            } else if ($data_resource) {
                $resource_url = "data://$resource_name";
                $url_parts = explode(";", $resource_name);
                $mime_type = $url_parts[0];
                $file_name = $resource_url; /* PHP can do file_get_contents on
                    data uri's*/
            } else if (!empty($qr_resource)) {
                /* Drawn here rather than by asking the qrencode command,
                   which a site had to install and point Yioop at. It is
                   drawn as squares rather than as a picture of squares,
                   so it stays sharp however large it is shown. */
                $drawn = L\QrCode::svg($resource_namespace_name);
                if ($drawn === false) {
                    continue;
                }
                $resource_url = "data:image/svg+xml;base64," .
                    base64_encode($drawn);
                $nolink_resource = true;
                $mime_type = "image/svg+xml";
                $file_name = $resource_url; /* PHP can do file_get_contents on
                    data uri's*/
            } else {
                $current_folder  = realpath($current_folder);
                $file_name = (empty($sub_path)) ?
                    "$current_folder/$resource_name"
                    : "$current_folder/$sub_path/$resource_name";
                $resource_pos = strpos($file_name, "resources");
                if ($resource_pos === false) {
                    $resource_path = "resources/" . substr($current_folder,
                        strrpos($current_folder, "/") + 1).
                        "/$sub_path/$resource_name";
                } else {
                    $resource_path = substr($file_name, $resource_pos);
                }
                $mime_type = L\mimeType($file_name);
                $mime_type_parts = explode(";", $mime_type);
                $mime_type = $mime_type_parts[0];
                $resource_url = $this->getGroupPageResourceUrl($csrf_token,
                    $group_id, $current_page_id, $resource_name, $sub_path);
                if (is_dir($file_name)) {
                    $is_dir = true;
                    $is_static = ($controller == 'static') ? true : false;
                    $page_info = $this->getPageInfoByPageId($current_page_id);
                    if (empty($page_info['PAGE_NAME'])) {
                        continue;
                    }
                    $resource_url =
                        htmlentities(B\wikiUrl($page_info['PAGE_NAME'] ,
                        true, $controller, $group_id));
                    if ($csrf_token != "") {
                        $resource_url .= "&amp;". C\p('CSRF_TOKEN') . "=" .
                            $csrf_token;
                    } else {
                        $resource_url .= "&amp;[{rtoken}]";
                    }
                }
            }
            if ($is_dir) {
                $new_sub_path = ($sub_path) ?
                    "$sub_path/$resource_name" : "$resource_name";
                $resource_url .= "&amp;sf=" . urlencode($new_sub_path);
                $replace_string = "<a href='$resource_url' >".
                    "$resource_description</a>";
                $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                    .'/u',  $replace_string, $parsed_page);
            } else if ($matches[1][$i] == "-thumb" &&
                !str_starts_with($mime_type, 'image') &&
                $this->makeThumbStripExif($resource_name,
                $current_folder, $current_thumb_folder, $mime_type)) {
                /* A file asked for as a thumb stands as the small
                   picture kept beside it, whatever the file is, so a
                   document shows its front page rather than opening in
                   a frame of its own. That picture lives at the file's
                   own address under another word. */
                $thumb_url = (strpos($resource_url, "wd/resources") !==
                    false) ? str_replace("wd/resources", "wd/thumbs",
                    $resource_url) : $resource_url . "&t=thumbs";
                $replace_string = "<a class='image-list' " .
                    "href='$resource_url' ><img src='$thumb_url' " .
                    " loading='lazy' alt='$resource_description' ></a>";
                $parsed_page = preg_replace('/' .
                    preg_quote($match_string, '/') . '/u',
                    $replace_string, $parsed_page);
            } else if (($matches[1][$i] == "-link")) {
                $replace_string = "<a href='$resource_url' >".
                    "$resource_description</a>";
                $parsed_page = preg_replace('/' . preg_quote($match_string, '/')
                    .'/u',  $replace_string, $parsed_page);
            } else if (in_array(substr($mime_type, 0, 5), ['image', 'video']) ||
                $mime_type == 'application/ogg') {
                $parsed_page = $this->insertVideoImageResourceParsePage(
                    $mime_type, $parsed_page, $thumb_resource, $nolink_resource,
                    $resource_name, $resource_url, $resource_description,
                    $autoplay, $current_folder, $data_resource, $match_string,
                    $locale_tag, $csrf_token, $group_id, $current_page_id,
                    $sub_path);
            } else if (in_array($mime_type, ['audio/aiff', 'audio/basic',
                'audio/L24', 'audio/mpeg', 'audio/mpeg3', 'audio/mp4',
                'audio/ogg', 'audio/opus',
                'audio/vorbis', 'audio/vnd.rn-realaudio', 'audio/vnd.wave',
                'audio/webm'])) {
                $audio_id = L\crawlHash($resource_name);
                /* The recording's words, when it carries any, are its
                   description, which sits inside the tag as its fallback
                   text; the reader's browser puts the toggle that shows
                   them beside the recording, so nothing more is drawn
                   here. */
                $replace_string = "<div class='audio-message-container'>\n".
                    "<div class='audio-player-wrapper'>\n".
                    "<audio controls='controls' $autoplay class='audio' " .
                        "id='$audio_id'>\n".
                    "<source src='$resource_url' type='$mime_type'>\n".
                    $resource_description."\n".
                    "</audio>\n".
                    "</div>\n".
                    "</div>";

                $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                    .'/u', $replace_string, $parsed_page);
            } else if ($mime_type == 'application/epub+zip' &&
                file_exists(C\APP_DIR .
                "/scripts/epubjs-reader/reader/index.html")) {
                $epub_reader_url = C\SHORT_BASE_URL .
                    "wd/scripts/epubjs-reader/reader/index.html";
                $replace_string = "<div class='wiki-resource-download'>".
                    "<a href='$resource_url' >⤓</a></div>";
                $resource_url = urlencode($resource_url);
                $resource_url = "$epub_reader_url?bookPath=$resource_url";
                $replace_string .= "<iframe class='wiki-resource-object' ".
                    "src='$resource_url' >$resource_description</iframe>";
                $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                    .'/u',  $replace_string, $parsed_page);
            } else if (in_array($mime_type, ['text/html', 'application/pdf'])) {
                $replace_string = "<div class='wiki-resource-download'>".
                    "<a href='$resource_url' >⤓</a></div>";
                $replace_string = "<iframe class='wiki-resource-object' ".
                    "src='$resource_url' >$resource_description</iframe>";
                $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                    .'/u',  $replace_string, $parsed_page);
            }  else if ($mime_type == 'text/csv') {
                if (!$include_charts_and_spreadsheets) {
                    continue;
                }
                /* The already-read ones are kept under $resources; the
                   test named $resource, which holds the text of the last
                   one read, so it never matched and every mention of a
                   file read it again. */
                if (!$data_chart && !isset($resources[$file_name])) {
                    $data_url_csv = 'data://text/csv;base64,';
                    if (substr($file_name, 0,
                        strlen($data_url_csv)) == $data_url_csv) {
                        $resource = base64_decode(substr($file_name,
                            strlen($data_url_csv)));
                    } else if (is_file($file_name)) {
                        $resource = file_get_contents($file_name);
                    } else {
                        /* A page may name a file it does not have, an
                           example written to be read rather than drawn
                           above all, so this is passed over rather than
                           read and complained about. */
                        continue;
                    }
                    $resources[$file_name] = L\parseCsv($resource);
                }
                if ($chart_resource) {
                    if (!$data_chart) {
                        $pre_x_vals = $this->evalRangeExpression(
                            "$x_start:$x_end", 0, $resources[$file_name]);
                        $x_values = $pre_x_vals[1];
                        $pre_y_vals = $this->evalRangeExpression(
                            "$y_start:$y_end", 0, $resources[$file_name]);
                        $y_values = $pre_y_vals[1];
                    }
                    /* A range naming one cell answers with that cell
                       rather than a list of them, so both sides are made
                       lists before they are put together, and a chart
                       whose two sides do not match is left out rather
                       than ending the request. */
                    $x_values = is_array($x_values) ? $x_values :
                        [$x_values];
                    $y_values = is_array($y_values) ? $y_values :
                        [$y_values];
                    if (count($x_values) != count($y_values) ||
                        empty($x_values)) {
                        continue;
                    }
                    $resource_data = json_encode(array_combine($x_values,
                        $y_values));
                    $replace_string = "<script>\n" .
                        "if (typeof chart_data === 'undefined') {\n" .
                        "    chart_data = [];\n".
                        "    chart_config = [];\n}\n".
                        "chart_data[$i] = $resource_data;" .
                        "chart_config[$i] = $chart_config;" .
                        "\n</script><div id='chart_$i'> </div>";
                    $parsed_page = preg_replace('/' .
                        preg_quote($match_string, '/')
                        .'/u',  $replace_string, $parsed_page, 1);
                } else {
                    $resource_data = json_encode(
                        $this->spreadsheetRectangleData(
                        $spreadsheet_rectangle, $resources[$file_name]));
                    if (isset($sheet_config)) {
                        $spread_config =
                            "spreadsheet_config[$i] = $sheet_config;";
                    } else {
                        $spread_config = "spreadsheet_config[$i] = {};";
                    }
                    if (!empty($spreadsheet_rectangle[0]) &&
                        $spreadsheet_rectangle[0] != [0, 0]) {
                        $spread_config .= "spreadsheet_config[$i]['offset'] =".
                            json_encode($spreadsheet_rectangle[0]) .";";
                    }
                    if (!empty($_SESSION['USER_NAME'])) {
                        $spread_config .=
                            "spreadsheet_config[$i]['user_name'] =".
                            json_encode($_SESSION['USER_NAME']) .";";
                    }
                    $spreadsheet_controls = "";
                    if ($verbose) {
                        /* The mark that swaps a table of figures between
                           its rows and bars. Only the drawing of the
                           figures knows there are any, so it is written
                           here and carried up into the row that names
                           which file is being read. The mark for
                           fetching the file down is drawn by that row,
                           since every file has one. */
                        $swaps_says =
                            L\tl('iconlink_helper_histograms');
                        $spreadsheet_controls =
                            "<span id='wiki-resource-swap'>" .
                            "<a class='icon-anchor-button' " .
                            "id='histogram-toggle-$i' " .
                            "href='javascript:" .
                            "toggleReadSpreadsheetView($i)' " .
                            "title='$swaps_says'><span role='img' " .
                            "class='icon-glyph' " .
                            "aria-label='$swaps_says'>" .
                            "\u{1F4CA}</span></a></span>";
                    }
                    $replace_string = "<script>\n" .
                        "if (typeof spreadsheet_marks === 'undefined') {\n" .
                        "    window.spreadsheet_marks = {bars: " .
                        json_encode("\u{1F4CA}") . ", rows: " .
                        json_encode("\u{25A6}") . "};\n" .
                        "    window.spreadsheet_says = {bars: " .
                        json_encode(L\tl('iconlink_helper_histograms')) .
                        ", rows: " .
                        json_encode(L\tl('iconlink_helper_spreadsheet')) .
                        "};\n}\n" .
                        "if (typeof spreadsheet_data === 'undefined') {\n" .
                        "    spreadsheet_data = [];\n".
                        "    spreadsheet_config = [];\n}\n".
                        "spreadsheet_data[$i] = $resource_data;" .
                        $spread_config .
                        "\n</script>".
                        $spreadsheet_controls .
                        "<div id='spreadsheet_$i'></div>";
                    $parsed_page = preg_replace('/' .
                        preg_quote($match_string, '/')
                        .'/u',  $replace_string, $parsed_page, 1);
                }
            } else if (str_starts_with($mime_type, 'text')) {
                $resource = file_get_contents($file_name);
                $replace_string = "<pre>\n" . htmlentities($resource) .
                    "\n</pre>";
                $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                    .'/u',  $replace_string, $parsed_page);
            } else {
                $replace_string = "<a href='$resource_url' >".
                    "$resource_description</a>";
                $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                    .'/u',  $replace_string, $parsed_page);
            }
        }
        return $parsed_page;
    }
    /**
     * insertVideoImageResourceParsePage auxiliary method for @see
     * insertResourcesParsePage used to insert video and image resources into an
     * otherwise parsed to HTML wiki page.
     * @param string $mime_type of resource to insert
     * @param string $parsed_page partiall parsed wiki page to insert resources
     *     into
     * @param bool $thumb_resource whether this is a thumbnail image resource.
     * @param bool $nolink_resource whether this is a nolink image resource (one
     *     not enclosed is a link to the resource).
     * @param string $resource_name name of resource that is being inserted
     * @param string $resource_url url of resource that's being inserted
     * @param string $resource_description human description of resource to be
     *     inserted
     * @param string $autoplay html code for attribute saying whether or not
     *     this is an autoplay resource
     * @param string $current_folder folder in which resource lives, so can
     *     check if there is an associated vtt transscript of audio
     * @param string $data_resource if the Video or Image resource is a data
     *     url, then the string of that data url. If this case we use this only
     *     to know if should pother with auxiliary source or track tags
     * @param string $match_string code string that was used to make the portion
     *     of the partially parsed wiki page to be replaced with htm for the
     *     resource to be inserted
     * @param string $locale_tag tag name language of wiki page
     * @param string $csrf_token cross site request forgery token to be used in
     *     links
     * @param int $group_id group of page into which resources are being
     *     inserted
     * @param int $current_page_id of page within group
     * @param string $sub_path of folder structure of wiki page from which
     *     resource comes
     * @return string the page with resources inserted
     */
    public function insertVideoImageResourceParsePage($mime_type, $parsed_page,
        $thumb_resource, $nolink_resource, $resource_name, $resource_url,
        $resource_description, $autoplay, $current_folder, $data_resource,
        $match_string, $locale_tag, $csrf_token, $group_id, $current_page_id,
        $sub_path)
    {
        $resource_path = parse_url($resource_url, \PHP_URL_PATH) ?? "";
        $path_info = pathinfo($resource_path);
        $dir_name = $path_info['dirname'] ?? "";
        $is_360 = (preg_match("/\b360\b/", $dir_name)) ? true : false;
        /* A file asked for as a thumb stands as the small picture kept
           beside it, whatever the file is, so a document shows its front
           page rather than opening in a frame of its own. The picture
           lives at the file's own address under another word. */
        if ($thumb_resource && !str_starts_with($mime_type, 'image')) {
            $thumb_url = (strpos($resource_url, "wd/resources") !== false) ?
                str_replace("wd/resources", "wd/thumbs", $resource_url) :
                $resource_url . "&t=thumbs";
            $replace_string = "<a class='image-list' " .
                "href='$resource_url' ><img src='$thumb_url' " .
                " loading='lazy' alt='$resource_description' ></a>";
            return preg_replace('/'. preg_quote($match_string, '/') .'/u',
                $replace_string, $parsed_page);
        }
        if (str_starts_with($mime_type, 'image')) {
            if ($thumb_resource) {
                $replace_string = "<a class='image-list' ".
                    "href='$resource_url' ><img src='$resource_url' ".
                    " loading='lazy' alt='$resource_description' ></a>";
            } else {
                if ($is_360) {
                    $resource_id = L\crawlHash($resource_url);
                    $replace_string = <<<EOD
<div class='photo-container'>
    <canvas id='p$resource_id' class='canvas-360' >
    <script>
    if (typeof yioop_post_scripts === 'undefined') {
        yioop_post_scripts = [];
    }
    yioop_post_scripts.push(function () {
        draw360('p$resource_id', '$resource_url');
    });
    </script>
</div>
EOD;
                } else if ($nolink_resource) {
                    $replace_string = "<img src='$resource_url' loading='lazy'".
                        " alt='$resource_description' class='photo' >";
                } else {
                    $replace_string = "<a href='$resource_url' ><img" .
                        " loading='lazy' src='$resource_url' ".
                        " alt='$resource_description' class='photo' ></a>";
                }
            }
            $parsed_page = preg_replace('/'. preg_quote($match_string,'/').'/u',
                $replace_string, $parsed_page);
        } else {
            $video_type_extensions = ['video/mp4' => "mp4",
                'video/ogg' => "ogv", 'video/avi' => 'avi',
                'video/quicktime' => 'mov',
                'video/x-flv' => 'flv',
                'video/x-ms-wmv' => 'wmv', 'video/webm' => 'webm',
                'application/ogg' => 'ogv'];
            $replace_string = "<video class='video' " .
                "controls='controls' $autoplay id='" .
                L\crawlHash($current_page_id . $resource_name . $sub_path) .
                    "' >\n".
                "<source src='$resource_url' type='$mime_type'/>\n";
            $multi_source_types = ["mp4", "webm", "ogg"];
            $current_extension = $video_type_extensions[$mime_type];
            $add_sources = [];
            if (empty($data_resource) &&
                !in_array($current_extension, $multi_source_types)) {
                $add_sources = array_diff($multi_source_types,
                    [$current_extension]);
            }
            $pre_name = substr($resource_name, 0,
                -strlen($current_extension) -1);
 /* add subtitles file if exists */
            $subtitle_file = "$pre_name-subtitles-$locale_tag.vtt";
            $all_subtitle_files = glob(
                "$current_folder/$pre_name-subtitles-*.vtt");
            if (empty($data_resource) && !empty($all_subtitle_files)) {
                foreach ($all_subtitle_files as $sub_file) {
                    preg_match("@$pre_name-subtitles-(.+).vtt@", $sub_file,
                        $matches);
                    if (!empty($matches[1])) {
                        $resource_url = $this->getGroupPageResourceUrl(
                            $csrf_token, $group_id, $current_page_id,
                            $matches[0], $sub_path);
                        $default = ($sub_file ==
                            "$current_folder/$subtitle_file") ?
                            "default" : "";
                        $tag = $matches[1];
                        $replace_string .= "<track src='$resource_url' " .
                            "label='$tag' kind='subtitles' " .
                            "srclang='$tag' $default >\n";
                    }
                }
            }
            $captions_file = "$pre_name-captions-$locale_tag.vtt";
            $all_captions_files = glob(
                "$current_folder/$pre_name-captions-*.vtt");
            if (empty($data_resource) && !empty($all_captions_files)) {
                foreach ($all_captions_files as $cap_file) {
                    preg_match("@$pre_name-captions-(.+).vtt@", $cap_file,
                        $matches);
                    if (!empty($matches[1])) {
                        $resource_url = $this->getGroupPageResourceUrl(
                            $csrf_token, $group_id, $current_page_id,
                            $matches[0], $sub_path);
                        $default = ($cap_file ==
                            "$current_folder/$captions_file") ?
                            "default" : "";
                        $tag = $matches[1];
                        $replace_string .= "<track src='$resource_url' " .
                            "label='$tag' kind='captions' " .
                            "srclang='$tag' $default >\n";
                    }
                }
            }
            foreach ($add_sources as $extension) {
                if (file_exists("$current_folder/$pre_name.$extension")) {
                    $resource_url = $this->getGroupPageResourceUrl(
                        $csrf_token, $group_id, $current_page_id,
                        "$pre_name.$extension", $sub_path);
                    $replace_string .= "<source src='$resource_url' ".
                        "type='video/$extension'/>\n";
                }
            }
            $replace_string .= $resource_description . "\n</video>";
            $parsed_page = preg_replace('/'.preg_quote($match_string, '/').'/u',
                $replace_string, $parsed_page);
        }
        return $parsed_page;
    }
    /**
     * versionGroupPage creates a new version of a wiki page in the
     * GROUP_PAGE_HISTORY without changing the page contents, but with an edit
     * reason. This function might be called when a resource has been added to
     * the page so that one can restore to a variant of the page with earlier
     * resource lists.
     * @param int $user_id of user responsible for version being created
     * @param int $page_id of page that new version is being made for
     * @param string $version_reason reason new version is being created; stored
     *     in the GROUP_PAGE_HISTORY EDIT_COMMENT column
     */
    public function versionGroupPage($user_id, $page_id, $version_reason)
    {
        list(, $page_name, $pages) = $this->getPageHistoryList($page_id, 0, 1);
        $pubdate = $pages[0]['PUBDATE'];
        $latest_page_info = $this->getHistoryPage($page_id, $pubdate);
        $page = $latest_page_info["PAGE"];
        $locale_tag =  $latest_page_info["LOCALE_TAG"];
        $group_id = $latest_page_info["GROUP_ID"];
        $this->insertPageHistory($this->db, $page_id, $user_id, $group_id,
            $page_name, $page, $locale_tag, time(), $version_reason);
    }
    /**
     * revertResources called to revert a wiki pages resources to those that
     * existed for the wiki page at a give time
     * @param int $page_id of page that new version is being made for
     * @param int $group_id of group wiki page belongs to
     * @param int $timestamp of when to revert resources back to
     */
    public function revertResources($page_id, $group_id, $timestamp)
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id);
        if (!$folders) {
            return;
        }
        list($folder, $thumb_folder, $base_folder,) = $folders;
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        $vcs->restoreVersion(intval($timestamp) + 1);
    }
    /**
     * deleteResource deletes a resource, such as a picture or a
     * video, kept with a
     * wiki page or group feed post belong to a group
     * @param string $resource_name name of resource to delete
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return bool whether the deletion was successful
     */
    public function deleteResource($resource_name, $group_id, $page_id,
        $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder) = $folders;
        $file_name = "$folder/$resource_name";
        $thumb_name = "$thumb_folder/$resource_name.webp";
        if (file_exists($file_name)) {
            $this->db->unlinkRecursive($file_name);
            $vcs = $this->versionManagerForPage($base_folder, $page_id);
            $vcs->createVersion($file_name);
        }
        if (file_exists($thumb_name)) {
            unlink($thumb_name);
        }
        return true;
    }
    /**
     * extractResource uncompresses a compressed resource associated with a wiki
     * page or group feed post belong to a group
     * @param string $resource_name name of resource to delete
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return bool whether the deletion was successful
     */
    public function extractResource($resource_name, $group_id, $page_id,
        $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder,,$base_folder,) = $folders;
        $file_name = "$folder/$resource_name";
        $zip_extractor = new \ZipArchive();
        if (!$zip_extractor) {
            return false;
        }
        $zip_extractor->open($file_name);
        $zip_extractor->extractTo($folder);
        $zip_extractor->close();
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        $vcs->createVersion($folder);
        return true;
    }
    /**
     * deleteResources deletes all resources (image, video, and so on)
     * associated with
     * a wiki page belonging to a group.
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return bool whether the deletion was successful
     */
    public function deleteResources($group_id, $page_id, $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder,) = $folders;
        if ($folder && file_exists($folder)) {
            $this->db->unlinkRecursive($folder, false);
            $vcs = $this->versionManagerForPage($base_folder, $page_id);
            $vcs->createVersion($folder);
        }
        if ($thumb_folder && file_exists($thumb_folder)) {
            $this->db->unlinkRecursive($thumb_folder, false);
        }
        return true;
    }
    /**
     * newResource create a new resource in the given group and page's resource
     * folder/sub_path of the type requests.
     * @param string $resource_type either new-file or new-folder
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return bool whether the deletion was successful
     */
    /**
     * blankImageBytes makes the bytes of an empty picture of the size a
     * writer asked for, so a new picture can be written as a file before
     * the drawing screen opens it.
     *
     * newResource calls this when a writer asks for a new picture. The
     * picture is filled white rather than left see-through, since a
     * writer drawing on it expects a sheet to draw on. A size outside
     * what is allowed is brought back inside it.
     *
     * @param int $wide how many pixels across the picture is
     * @param int $tall how many pixels down the picture is
     * @return string the picture as the bytes of a webp file
     */
    public function blankImageBytes($wide, $tall)
    {
        $wide = max(1, min(C\MAX_NEW_IMAGE_SIDE, intval($wide)));
        $tall = max(1, min(C\MAX_NEW_IMAGE_SIDE, intval($tall)));
        $sheet = imagecreatetruecolor($wide, $tall);
        imagefill($sheet, 0, 0, imagecolorallocate($sheet, 255, 255, 255));
        ob_start();
        imagewebp($sheet);
        return ob_get_clean();
    }
    /**
     * makeBlankThumb writes the small picture standing for a new empty
     * picture kept with a page.
     *
     * newResource calls this after it writes a new picture. The ordinary
     * maker leaves a picture of one color alone, since a small picture of
     * it would say nothing, but a file list needs something to draw or
     * the place where the picture belongs reads as broken. The small
     * picture keeps the shape of the one it stands for.
     *
     * @param string $file_name what the new picture is called
     * @param string $thumb_folder where small pictures for this page live
     * @param int $wide how many pixels across the new picture is
     * @param int $tall how many pixels down the new picture is
     */
    public function makeBlankThumb($file_name, $thumb_folder, $wide,
        $tall)
    {
        $wide = max(1, intval($wide));
        $tall = max(1, intval($tall));
        $side = C\THUMB_DIM;
        $across = ($wide >= $tall) ? $side :
            max(1, intval($side * $wide / $tall));
        $down = ($wide >= $tall) ? max(1, intval($side * $tall / $wide)) :
            $side;
        $small = imagecreatetruecolor($across, $down);
        imagefill($small, 0, 0, imagecolorallocate($small, 255, 255, 255));
        $edge = imagecolorallocate($small, 128, 128, 128);
        imagerectangle($small, 0, 0, $across - 1, $down - 1, $edge);
        ob_start();
        imagewebp($small);
        file_put_contents("$thumb_folder/$file_name.webp",
            ob_get_clean());
    }
    /**
     * newResource makes a new file or folder in a page's own folder.
     *
     * The file list's actions call this when a writer asks for a folder,
     * a file of text, a file of comma separated values, or a picture.
     * The new thing is named untitled with the first number free, so two
     * asks in a row do not collide. A picture is made at the size asked
     * for and given a small picture to stand for it in the list.
     *
     * @param string $resource_type which of the four kinds to make
     * @param int $group_id which group's page the folder belongs to
     * @param int $page_id identifier of the page the folder belongs to
     * @param string $sub_path folder under the page to make it in
     * @param int $wide how many pixels across a new picture is
     * @param int $tall how many pixels down a new picture is
     * @return mixed the name of a new picture, true where another kind
     *     was made, and false where nothing was
     */
    public function newResource($resource_type, $group_id, $page_id,
        $sub_path = "", $wide = 0, $tall = 0)
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder,) = $folders;
        if (!file_exists($folder)) {
            return false;
        }
        $i = 0;
        $base_name = ($resource_type == 'new-text-file') ? "untitled%d.txt" :
            (($resource_type == 'new-csv-file') ? "untitled%d.csv" :
            (($resource_type == 'new-image-file') ? "untitled%d.webp" :
            "untitled_folder%d"));
        do {
            $file_name = sprintf($folder . "/" . $base_name, $i);
            $i++;
        } while (file_exists($file_name));
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        if ($resource_type == 'new-text-file') {
            return ($vcs->headPutContents($file_name, "") ==
                VersionManager::SUCCESS);
        }
        if ($resource_type == 'new-csv-file') {
            $csv = ",,,,\n,,,,\n,,,,\n,,,,\n,,,,\n";
            return ($vcs->headPutContents($file_name, $csv) ==
                VersionManager::SUCCESS);
        }
        if ($resource_type == 'new-image-file') {
            if ($vcs->headPutContents($file_name,
                $this->blankImageBytes($wide, $tall)) ==
                VersionManager::SUCCESS) {
                $this->makeBlankThumb(basename($file_name),
                    $thumb_folder, $wide, $tall);
                return basename($file_name);
            }
            return false;
        }
        return ($vcs->headMakeDirectory($file_name) ==
            VersionManager::SUCCESS);
    }
    /**
     * clearGroupPageResourceLock removes a leftover lock file from a wiki
     * page's resource version history. If an earlier resource operation stopped
     * partway it can leave this lock in place, after which later operations
     * quietly do nothing. Clearing it lets resource operations work again.
     * @param int $group_id group the wiki page belongs to
     * @param int $page_id identifier of the wiki page
     * @param string $sub_path subfolder of the page's resource folder to act
     *     on, if any
     * @return bool true if there is no lock afterward
     */
    public function clearGroupPageResourceLock($group_id, $page_id,
        $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!is_array($folders) || !isset($folders[2])) {
            return false;
        }
        $base_folder = $folders[2];
        $lock_file = $base_folder . "/.archive/LOCK";
        if (file_exists($lock_file)) {
            unlink($lock_file);
        }
        return !file_exists($lock_file);
    }
    /**
     * versionGroupPageResource saves a fresh version snapshot of a wiki page's
     * resource folder. Useful for getting the version history back in step with
     * what is actually in the folder after manual fixes.
     * @param int $group_id group the wiki page belongs to
     * @param int $page_id identifier of the wiki page
     * @param string $sub_path subfolder of the page's resource folder to act
     *     on, if any
     * @return bool true if a snapshot was saved
     */
    public function versionGroupPageResource($group_id, $page_id,
        $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!is_array($folders) || !isset($folders[2])) {
            return false;
        }
        $base_folder = $folders[2];
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        return $vcs->createVersion() !== VersionManager::LOCK_FAIL;
    }
    /**
     * setResourceDescription sets the text description file of a resource
     * @param string $resource_name name of resource to set description for
     * @param string $resource_description description of the resource
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return bool whether the deletion was successful
     */
    public function setResourceDescription($resource_name,
        $resource_description, $group_id, $page_id, $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder,) = $folders;
        if (!file_exists($thumb_folder)) {
            return false;
        }
        $description_file = $thumb_folder ."/$resource_name.txt";
        return file_put_contents($description_file, $resource_description);
    }
    /**
     * getResourceDescription gets the text description file of a resource
     * @param string $resource_name name of resource to set description for
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return string the text description of a resource
     */
    public function getResourceDescription($resource_name, $group_id, $page_id,
        $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder,) = $folders;
        if (!file_exists($thumb_folder)) {
            return false;
        }
        $description_file = $thumb_folder ."/$resource_name.txt";
        if (file_exists($description_file)) {
            return file_get_contents($description_file);
        }
        return false;
    }
    /**
     * renameResource renames a resource (image, video, and so on) associated
     * with a
     * wiki page belonging to a group.
     * @param string $old_resource_name name of resource before renaming
     * @param string $new_resource_name name of resource after renaming
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to delete resource from
     * @param string $sub_path path to a subfolder of default resource folder if
     *     desired
     * @return bool whether the deletion was successful
     */
    public function renameResource($old_resource_name, $new_resource_name,
        $group_id, $page_id, $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder, ) = $folders;
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        $old_file_name = "$folder/$old_resource_name";
        if (file_exists($old_file_name)) {
            $vcs->headRename($old_file_name, "$folder/$new_resource_name");
        } else {
            return false;
        }
        $old_thumb_paths = glob("$thumb_folder/$old_resource_name.*");
        $thumb_folder_len = strlen("$thumb_folder/");
        foreach ($old_thumb_paths as $old_thumb_path) {
            $old_thumb_name = substr($old_thumb_path, $thumb_folder_len);
            $new_thumb_name = str_replace($old_resource_name,
                $new_resource_name, $old_thumb_name);
            rename($old_thumb_path, "$thumb_folder/$new_thumb_name");
        }
        return true;
    }
    /**
     * linkResourceFolders creates a symlink between two resource folders (and
     * their associate thumb folders)
     * @param string $link_group_id group with page that is going to get the
     *     symlink
     * @param string $link_store_id page_id of page that is going to get the
     *     symlink
     * @param string $target_group_id group whose page resources will be linked
     *     to
     * @param string $target_store_id page whose page resources will be linked
     *     to
     */
    public function linkResourceFolders($link_group_id, $link_store_id,
        $target_group_id, $target_store_id)
    {
        $link_folder_names = $this->getGroupPageResourcesFolders(
            $link_group_id, $link_store_id, "", false, false, false);
        $target_folder_names = $this->getGroupPageResourcesFolders(
            $target_group_id, $target_store_id, "", false, false, false);
        for ($i = 0; $i < 2; $i++) {
            if (!file_exists($link_folder_names[$i])) {
                symlink($target_folder_names[$i], $link_folder_names[$i]);
            }
        }
    }
    /**
     * copyFileToGroupPageResource moves a file that has been uploaded via a
     * wiki pages resource form to its correct position in the resources folder
     * so it shows up for that page. Thumbnails are generated for images
     * and for video; the video ones come from Yioop's own video library,
     * so no outside program is needed. For video, if FFMPEG is
     * configured, a schedule is also added to the media_convert folder
     * so that the media_updater can produce mp4 and webm files
     * corresponding to the video file.
     * @param string $tmp_name tmp location that uploaded file initially stored
     *     at
     * @param string $file_name file name of file that has been uploaded
     * @param string $mime_type mime type of uploaded file
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want copy a page resource for
     * @param string $sub_path used to specify sub-folder of default resource
     *     folder to copy to
     * @param string $data string data for file to use instead of filename (only
     *     used in case run non-empty)
     * @param int $timestamp seconds-since-epoch to set on the destination
     *     file's mtime; 0 leaves the system default in place
     * @return bool false if the resource folder couldn't be resolved; void on
     *     the success path
     */
    public function copyFileToGroupPageResource($tmp_name, $file_name,
        $mime_type, $group_id, $page_id, $sub_path = "", $data = "",
        $timestamp = 0)
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
            $sub_path, true);
        if (!is_array($folders) || !isset($folders[2])) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder,) = $folders;
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        if (empty($data)) {
            if (!move_uploaded_file($tmp_name, "$folder/$file_name")) {
                return false;
            }
            $vcs->createVersion("$folder/$file_name", "", $timestamp);
        } else {
            $vcs->headPutContents("$folder/$file_name", $data, true,
                $timestamp);
        }
        $this->makeThumbStripExif($file_name, $folder, $thumb_folder,
            $mime_type);
        $file_name = $this->convertVoiceMessageToMp4($file_name, $folder,
            $mime_type, $vcs, $timestamp);
    }
    /**
     * getPageResource reads in and returns as a string the contents of a
     * resource that has been associated to a page.
     * @param string $file_name file name of page resource desired
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want copy a page resource for
     * @param string $sub_path subpath with the resource folder that should be
     *     used to look up filename in
     * @param bool $raw if csv file don't content to array of rows
     * @return string desired page resource
     */
    public function getPageResource($file_name, $group_id, $page_id,
        $sub_path = "", $raw = false)
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
            $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder) = $folders;
        $contents = file_get_contents("$folder/$file_name");
        $name_parts = pathinfo($file_name);
        if (!$raw && !empty($name_parts['extension']) &&
            $name_parts['extension'] == 'csv') {
            $contents = json_encode(L\parseCsv($contents));
        }
        return $contents;
    }
    /**
     * hasPageResource says yes where a file of the given name is
     * already kept beside a wiki page.
     *
     * A save that names a file of its own calls this before it writes,
     * so that a writer who picks a name another file already has is
     * told, rather than having that file written over. A page with no
     * resource folder yet holds no such file, so this answers no.
     *
     * @param string $file_name name of the file being asked after
     * @param int $group_id group identifier of the group the page
     *     belongs to
     * @param int $page_id identifier of the page the file sits beside
     * @param string $sub_path folder under the page's resource folder
     *     the file would sit in
     * @return bool true where a file of that name is already there
     */
    public function hasPageResource($file_name, $group_id, $page_id,
        $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
            $sub_path);
        if (!$folders) {
            return false;
        }
        list($folder, ) = $folders;
        return file_exists("$folder/$file_name");
    }
    /**
     * setPageResource saves the string for an page resource that has been
     * updated to the appropriate folder for that wiki page.
     * @param string $file_name file name of page resource desired
     * @param string $resource_data the data to be saved
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want copy a page resource for
     * @param string $sub_path subpath with the resource folder that should be
     *     add to resource path and filename
     * @return bool false if the resource folder couldn't be resolved or a CSV
     *     payload couldn't be decoded; void on the success path
     */
    public function setPageResource($file_name, $resource_data, $group_id,
        $page_id, $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
            $sub_path, true);
        if (!$folders) {
            return false;
        }
        list($folder, $thumb_folder) = $folders;
        $vcs = $this->versionManagerForPage($folder, $page_id);
        $name_parts = pathinfo($file_name);
        if (!empty($name_parts['extension']) &&
            $name_parts['extension'] == 'csv') {
            $lines = json_decode($resource_data);
            if (!is_array($lines)) {
                return false;
            }
            $resource_data = "";
            foreach ($lines as $line) {
                $resource_data .= L\arraytoCsv($line) ."\n";
            }
        }
        return ($vcs->headPutContents("$folder/$file_name", $resource_data)
            == VersionManager::SUCCESS);
    }
    /**
     * getClipboardResourceNames get the names of the resources in the clipboard
     * of $user_id
     * @param int $user_id of user we want to get clipboard resource names for
     * @return array names of resources in clipboard
     */
    public function getClipboardResourceNames($user_id)
    {
        if (($clip_group_id = $this->getPersonalGroupId($user_id)) < 0) {
            return false;
        }
        if (!($clip_page_id = $this->getPageId($clip_group_id,
            C\CLIPBOARD_PAGE_NAME, C\p('DEFAULT_LOCALE')) ) ) {
            $clip_page_id = $this->setPageName($user_id, $clip_group_id,
                C\CLIPBOARD_PAGE_NAME, "page_type=media_list\n\n" .
                WikiParser::END_HEAD_VARS .
                C\CLIPBOARD_PAGE_NAME, C\p('DEFAULT_LOCALE'), "create", "", "");
            if (!$clip_page_id) {
                return false;
            }
        }
        if (!($folders = $this->getGroupPageResourcesFolders($clip_group_id,
            $clip_page_id, "", true))) {
            return false;
        }
        list($clip_folder, ) = $folders;
        if (!is_dir($clip_folder)) {
            return false;
        }
        $clip_folder_len = strlen("$clip_folder/");
        $resource_paths = glob("$clip_folder/*");
        $resource_names = [];
        foreach ($resource_paths as $resource_path) {
            $resource_names[] = substr($resource_path, $clip_folder_len);
        }
        return $resource_names;
    }
    /**
     * emptyClipFolder deletes the resources in the clipboard of user with id
     * $user_id
     * @param int $user_id that we want to delete the contents of the clipboard
     *     for
     * @return bool true on successful clear, false when the personal group,
     *     clipboard page, or clip folder couldn't be resolved
     */
    public function emptyClipFolder($user_id)
    {
        if (($clip_group_id = $this->getPersonalGroupId($user_id)) < 0) {
            return false;
        }
        if (!($clip_page_id = $this->getPageId($clip_group_id,
            C\CLIPBOARD_PAGE_NAME, C\p('DEFAULT_LOCALE')) ) ) {
            return false;
        }
        $folders = $this->getGroupPageResourcesFolders($clip_group_id,
            $clip_page_id);
        list($clip_folder, $clip_thumb_folder, $base_clip_folder, ) = $folders;
        if (empty($clip_folder) || !is_dir($clip_folder)) {
            return false;
        }
        $this->db->unlinkRecursive($clip_folder, false);
        if (!empty($clip_thumb_folder) && is_dir($clip_thumb_folder)) {
            $this->db->unlinkRecursive($clip_thumb_folder, false);
        }
        return true;
    }
    /**
     * pasteAllClipFolder used to paste all resources from the user's clip
     * folder to the provided folder
     * @param int $user_id of user whose clip_folder copying to
     * @param int $group_id id of group the file resource belongs to
     * @param int $page_id id of page the file resource belongs to
     * @param string $sub_path path within the page resource folder to the
     *     folder that contains the resource to copy
     * @return bool true if every resource in the clipboard was pasted
     *     successfully, false on any folder-resolution or copy failure
     */
    public function pasteAllClipFolder($user_id, $group_id, $page_id,
        $sub_path = "")
    {
        if (($clip_group_id = $this->getPersonalGroupId($user_id)) < 0) {
            return false;
        }
        if (!($clip_page_id = $this->getPageId($clip_group_id,
            C\CLIPBOARD_PAGE_NAME, C\p('DEFAULT_LOCALE')) ) ) {
            return false;
        }
        if (!($folders = $this->getGroupPageResourcesFolders($clip_group_id,
            $clip_page_id))) {
            return false;
        }
        list($clip_folder, ) = $folders;
        if (!is_dir($clip_folder)) {
            return false;
        }
        $clip_folder_len = strlen("$clip_folder/");
        $resource_paths = glob("$clip_folder/*");
        foreach ($resource_paths as $resource_path) {
            $resource_name = substr($resource_path, $clip_folder_len);
            if (!$this->pasteFromClipFolder($user_id,
                $resource_name, $group_id, $page_id, $sub_path)) {
                return false;
            }
        }
        return true;
    }
    /**
     * pasteFromClipFolder used to paste a resource from the user's clip folder
     * to the provided folder
     * @param int $user_id of user whose clip_folder copying to
     * @param string $resource_name what to copy
     * @param int $group_id id of group the file resource belongs to
     * @param int $page_id id of page the file resource belongs to
     * @param string $sub_path path within the page resource folder to the
     *     folder that contains the resource to copy
     * @return bool true on successful paste, false when the clip transfer paths
     *     could not be resolved
     */
    public function pasteFromClipFolder($user_id,
        $resource_name, $group_id, $page_id, $sub_path = "")
    {
        $transfer_paths = $this->getClipTransferPaths($user_id, $resource_name,
            $group_id, $page_id, $sub_path);
        if (empty($transfer_paths)) {
            return false;
        }
        list($file_path, $thumb_folder, $clip_path,
            $clip_thumb_folder, $base_folder) = $transfer_paths;
        $this->db->copyRecursive($clip_path, $file_path);
        if (is_dir($clip_path)) {
            $thumb_path = "$thumb_folder/$resource_name";
            $clip_thumb_path = "$clip_thumb_folder/$resource_name";
            $this->db->copyRecursive($clip_thumb_path, $thumb_path);
        } else {
            $clip_thumb_resources = glob("$clip_thumb_folder/$resource_name.*");
            $clip_thumb_folder_len = strlen("$clip_thumb_folder/");
            foreach ($clip_thumb_resources as $clip_thumb_resource_path) {
                $clip_thumb_resource_name =  substr($clip_thumb_resource_path,
                    $clip_thumb_folder_len);
                $this->db->copyRecursive($clip_thumb_resource_path,
                    "$thumb_folder/$clip_thumb_resource_name");
            }
        }
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        $vcs->createVersion($file_path);
        return true;
    }
    /**
     * copyResourceToClipFolder used to copy a resource in the provided folder
     * to the user's clip folder
     * @param int $user_id of user whose clip_folder copying to
     * @param string $resource_name what to copy
     * @param int $group_id id of group the file resource belongs to
     * @param int $page_id id of page the file resource belongs to
     * @param string $sub_path path within the page resource folder to the
     *     folder that contains the resource to copy
     * @return bool true on successful copy, false when the clip transfer paths
     *     could not be resolved
     */
    public function copyResourceToClipFolder($user_id,
        $resource_name, $group_id, $page_id, $sub_path = "")
    {
        $transfer_paths = $this->getClipTransferPaths($user_id, $resource_name,
            $group_id, $page_id, $sub_path);
        if (empty($transfer_paths)) {
            return false;
        }
        list($file_path, $thumb_folder, $clip_path,
            $clip_thumb_folder,) = $transfer_paths;
        $this->db->copyRecursive($file_path, $clip_path);
        if (is_dir($file_path)) {
            $thumb_path = "$thumb_folder/$resource_name";
            $clip_thumb_path = "$clip_thumb_folder/$resource_name";
            $this->db->copyRecursive($thumb_path, $clip_thumb_path);
        } else {
            $thumb_resources = glob("$thumb_folder/$resource_name.*");
            $thumb_folder_len = strlen("$thumb_folder/");
            foreach ($thumb_resources as $thumb_resource_path) {
                $thumb_resource_name =  substr($thumb_resource_path,
                    $thumb_folder_len);
                $this->db->copyRecursive($thumb_resource_path,
                    "$clip_thumb_folder/$thumb_resource_name");
            }
        }
        return true;
    }
    /**
     * moveResourceToClipFolder used to move a resource in the provided folder
     * to the user's clip folder
     * @param int $user_id of user whose clip_folder moving to
     * @param string $resource_name what to move
     * @param int $group_id id of group the file resource belongs to
     * @param int $page_id id of page the file resource belongs to
     * @param string $sub_path path within the page resource folder to the
     *     folder that contains the resource to move
     * @return bool true on successful move, false when the clip transfer paths
     *     could not be resolved
     */
    public function moveResourceToClipFolder($user_id,
        $resource_name, $group_id, $page_id, $sub_path = "")
    {
        $transfer_paths = $this->getClipTransferPaths($user_id, $resource_name,
            $group_id, $page_id, $sub_path);
        if (empty($transfer_paths)) {
            return false;
        }
        list($file_path, $thumb_folder, $clip_path,
            $clip_thumb_folder, $base_folder) = $transfer_paths;
        rename($file_path, $clip_path);
        if (is_dir($file_path)) {
            $thumb_path = "$thumb_folder/$resource_name";
            $clip_thumb_path = "$clip_thumb_folder/$resource_name";
            rename($thumb_path, $clip_thumb_path);
        } else {
            $thumb_resources = glob("$thumb_folder/$resource_name.*");
            $thumb_folder_len = strlen("$thumb_folder/");
            foreach ($thumb_resources as $thumb_resource_path) {
                $thumb_resource_name =  substr($thumb_resource_path,
                    $thumb_folder_len);
                rename($thumb_resource_path,
                    "$clip_thumb_folder/$thumb_resource_name");
            }
        }
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        $vcs->createVersion($file_path);
        return true;
    }
    /**
     * moveResourceToFolder moves a resource, with any thumbnails it has, from
     * the folder currently being viewed into one of that folder's subfolders,
     * and records the change in the page's version history. This is what
     * happens when someone drags a resource onto a folder: the resource is
     * relocated the same way a clipboard cut-and-paste would move it, without
     * going through the clipboard.
     * @param string $resource_name name of the file or folder to move
     * @param string $target_folder name of the subfolder (inside the folder
     *     being viewed) to move the resource into
     * @param int $group_id group the wiki page belongs to
     * @param int $page_id identifier of the wiki page
     * @param string $sub_path folder currently being viewed, relative to the
     *     page's resource folder
     * @return bool true if the resource was moved
     */
    public function moveResourceToFolder($resource_name, $target_folder,
        $group_id, $page_id, $sub_path = "")
    {
        if ($target_folder === "" || $resource_name === $target_folder) {
            return false;
        }
        $target_sub_path = ($sub_path === "") ? $target_folder :
            trim($sub_path, "/") . "/" . $target_folder;
        return $this->moveResourceBetweenSubPaths($resource_name,
            $sub_path, $target_sub_path, $group_id, $page_id);
    }
    /**
     * moveResourceToSubPath moves a resource, with any thumbnails it has, from
     * the folder currently being viewed to another folder named by its full
     * path under the page's resource folder. This is what happens when someone
     * drags a resource onto a folder in the breadcrumb path, for example to
     * move it up into a parent folder.
     * @param string $resource_name name of the file or folder to move
     * @param string $target_sub_path destination folder relative to the page's
     *     resource folder, empty for the top folder
     * @param int $group_id group the wiki page belongs to
     * @param int $page_id identifier of the wiki page
     * @param string $sub_path folder currently being viewed, relative to the
     *     page's resource folder
     * @return bool true if the resource was moved
     */
    public function moveResourceToSubPath($resource_name,
        $target_sub_path, $group_id, $page_id, $sub_path = "")
    {
        $target_sub_path = trim(str_replace("\\", "/", $target_sub_path),
            "/");
        if (trim($sub_path, "/") === $target_sub_path) {
            return false;
        }
        return $this->moveResourceBetweenSubPaths($resource_name,
            $sub_path, $target_sub_path, $group_id, $page_id);
    }
    /**
     * moveResourceBetweenSubPaths moves a resource, with any thumbnails it has,
     * from one folder to another within a wiki page's resource folder, and
     * records the change in the page's version history. Both folders are given
     * by their path under the page's resource folder. Shared by the drag-onto-
     * folder and drag-onto-breadcrumb move actions.
     * @param string $resource_name name of the file or folder to move
     * @param string $source_sub_path folder the resource is currently in,
     *     relative to the page's resource folder
     * @param string $target_sub_path folder to move the resource into, relative
     *     to the page's resource folder
     * @param int $group_id group the wiki page belongs to
     * @param int $page_id identifier of the wiki page
     * @return bool true if the resource was moved
     */
    private function moveResourceBetweenSubPaths($resource_name,
        $source_sub_path, $target_sub_path, $group_id, $page_id)
    {
        if ($resource_name === "") {
            return false;
        }
        $source_folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $source_sub_path);
        if (!is_array($source_folders) || !isset($source_folders[2])) {
            return false;
        }
        list($folder, $thumb_folder, $base_folder, ) = $source_folders;
        $file_path = "$folder/$resource_name";
        if (!file_exists($file_path)) {
            return false;
        }
        $target_folders = $this->getGroupPageResourcesFolders($group_id,
            $page_id, $target_sub_path, true);
        if (!is_array($target_folders) || !isset($target_folders[1])) {
            return false;
        }
        list($dest_folder, $dest_thumb_folder, , ) = $target_folders;
        $real_base = realpath($base_folder);
        $real_dest_folder = realpath($dest_folder);
        if ($real_base === false || $real_dest_folder === false ||
            strncmp($real_dest_folder, $real_base, strlen($real_base)) != 0) {
            return false;
        }
        $dest_path = "$dest_folder/$resource_name";
        if (file_exists($dest_path)) {
            /* a resource of that name is already there, so it is replaced;
               a move that quietly did nothing instead reads as the drag
               having failed */
            if (is_dir($dest_path)) {
                return false;
            }
            unlink($dest_path);
        }
        rename($file_path, $dest_path);
        if (is_dir($dest_path)) {
            $thumb_path = "$thumb_folder/$resource_name";
            if (file_exists($thumb_path)) {
                rename($thumb_path, "$dest_thumb_folder/$resource_name");
            }
        } else {
            $thumb_resources = glob("$thumb_folder/$resource_name.*");
            $thumb_folder_len = strlen("$thumb_folder/");
            foreach ($thumb_resources as $thumb_resource_path) {
                $thumb_resource_name = substr($thumb_resource_path,
                    $thumb_folder_len);
                rename($thumb_resource_path,
                    "$dest_thumb_folder/$thumb_resource_name");
            }
        }
        $vcs = $this->versionManagerForPage($base_folder, $page_id);
        $vcs->createVersion($folder);
        return true;
    }
    /**
     * getGroupPageResourceUrls gets all the urls of resources belonging to a
     * particular groups wiki page.
     * @param int $group_id group identifier of group wiki page belongs to
     * @param int $page_id identifier for page want to get page resources for
     * @param string $sub_path additional path beneath the default folder used
     *     for the resource folder
     * @param bool $create if folder doesn't exist whether to create it or not
     * @param string $needs_descriptions_format optional filter controlling
     *     which entries get a needs_description.txt path attached; one of
     *     'files-only', 'folders-only', 'files-and-folders', or null to skip
     *     description-file resolution entirely
     * @return array (url_prefix - prefix to apply to all urls, thum_prefix
     *     prefix to apply to a resource name to get its thumb, list of
     *     resources). Each resource is an pair (name - string file name of the
     *     resource, has_thumb a boolean as to whether the resource has a thumb)
     */
    public function getGroupPageResourceUrls($group_id, $page_id, $sub_path ="",
        $create = false, $needs_descriptions_format = null)
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id,
            $sub_path, $create);
        if (!$folders) {
            return false;
        }
        $find_files_descriptions = (in_array($needs_descriptions_format,
            ['files-only', 'files-and-folders'])) ? true : false;
        $find_folders_descriptions = (in_array($needs_descriptions_format,
            ['folders-only', 'files-and-folders'])) ? true : false;
        list($folder, $thumb_folder) = $folders;
        $folder_len = strlen($folder) + 1;
        $pre_resources = glob(preg_replace('/([*?\[])/', '[$1]',
            $folder) . "/*");
        $subfolder_counts_file = "";
        if (!empty($thumb_folder)) {
            $subfolder_counts_file = $thumb_folder . "/subfolder_counts.txt";
            $needs_description_file = $thumb_folder . "/needs_description.txt";
        }
        $parent_counts_file = "";
        $pre_thumbs = [];
        $resource_info['folder'] = $folder;
        $subfolder_counts_change = true;
        $subfolder_counts = [];
        $needs_description_string = "";
        if (!empty($thumb_folder)) {
            $resource_info['thumb_folder'] = $thumb_folder;
            $thumb_len = strlen($thumb_folder) + 1;
            /* glob, not preg: escape only glob's own wildcards, since
               preg_quote backslashes path punctuation and a glob that
               reads a backslash as a literal then matches no files. */
            $pre_thumbs = glob(preg_replace('/([*?\[])/', '[$1]',
                $thumb_folder) . "/*");
            if (!empty($subfolder_counts_file) &&
                file_exists($subfolder_counts_file)) {
                $subfolder_counts = unserialize(
                    file_get_contents($subfolder_counts_file));
                $subfolder_counts_change = false;
            }
            if (!empty($sub_path) && !empty($thumb_folder)) {
                $thumb_path_parts = explode("/", $thumb_folder);
                array_pop($thumb_path_parts);
                $parent_path = implode("/", $thumb_path_parts);
                $parent_counts_file = $parent_path . "/subfolder_counts.txt";
                $parent_counts = [];
                if (file_exists($subfolder_counts_file)) {
                    $parent_counts = unserialize(
                        file_get_contents($parent_counts_file));
                }
            }
        }
        $thumbs = [];
        foreach ($pre_thumbs as $pre_thumb) {
            $thumb_name = substr($pre_thumb, $thumb_len);
            /* keyed by name so the per-resource has-thumb checks below are
               O(1) lookups; with one to two thousand resources and as many
               thumbnails a linear in_array scan made the whole assembly
               grow with the square of the count and hang the server */
            $thumbs[$thumb_name] = true;
        }
        $resource_info['default_folder_writable'] = false;
        if (is_writable($folder)) {
            $resource_info['default_folder_writable'] = true;
        }
        if (C\REDIRECTS_ON) {
            $url_common = "-/$group_id/$page_id";
            $resource_info['url_prefix'] = C\SHORT_BASE_URL .
                "wd/resources/$url_common";
            $resource_info['thumb_prefix'] = C\SHORT_BASE_URL .
                "wd/thumbs/$url_common";
            $resource_info['athumb_prefix'] = C\SHORT_BASE_URL .
                "wd/athumbs/$url_common";
        } else {
            $resource_info['url_prefix'] = C\SHORT_BASE_URL .
                "?c=resource&amp;a=get&amp;f=resources".
                "&amp;g=$group_id&amp;p=$page_id";
            $resource_info['thumb_prefix'] = $resource_info['url_prefix'] .
                "&amp;t=thumb";
            $resource_info['athumb_prefix'] = $resource_info['url_prefix'] .
                "&amp;t=" . C\MOVING_THUMB_ARG;
        }
        $sub_path = str_replace("..", "", $sub_path);
        $sub_path = str_replace("/./", "/", $sub_path);
        $sub_path = htmlentities($sub_path);
        if ($sub_path != "" && $sub_path != "/") {
            if (C\REDIRECTS_ON) {
                $resource_info['url_prefix'] .= "/" . urlencode(
                    urlencode($sub_path));
                $resource_info['thumb_prefix'] .= "/" . urlencode(
                    urlencode($sub_path));
                $resource_info['athumb_prefix'] .= "/" . urlencode(
                    urlencode($sub_path));
            } else {
                $resource_info['url_prefix'] .= "&amp;sf=" .
                    urlencode($sub_path);
                $resource_info['thumb_prefix'] .= "&amp;sf=" .
                    urlencode($sub_path);
                $resource_info['athumb_prefix'] .= "&amp;sf=" .
                    urlencode($sub_path);
            }
        }
        $resource_info = array_merge($resource_info,
            (['default_thumb' => "resources/file-icon.png",
                'default_editable_thumb' => "resources/editable-resource.png",
                'default_folder_thumb' => "resources/folder.png"]));
        $resources = [];
        $time = time();
        $missing_thumb = false;
        foreach ($pre_resources as $pre_resource) {
            if (!file_exists($pre_resource)) {
                continue;
            }
            $resource = [];
            $name = substr($pre_resource, $folder_len);
            $resource['name'] = $name;
            $resource['size'] = filesize($pre_resource);
            $resource['modified'] = filemtime($pre_resource);
            $resource['has_thumb'] = false;
            $resource['has_animated_thumb'] = false;
            $resource['is_dir'] = false;
            $resource['is_compressed'] = false;
            $resource['is_writable'] = false;
            $resource['media_type'] = false;
            $might_have_thumb = false;
            $might_have_animated_thumb = false;
            if (is_dir($pre_resource)) {
                $resource['is_dir'] = true;
                if (!empty($subfolder_counts[$pre_resource])) {
                    $resource['num_files'] = $subfolder_counts[$pre_resource];
                } else {
                    $resource['num_files'] = iterator_count(
                        new \FilesystemIterator($pre_resource));
                    $subfolder_counts[$pre_resource] = $resource['num_files'];
                    $subfolder_counts_change = true;
                }
                if ($find_folders_descriptions && !empty($thumb_folder) &&
                    !file_exists($thumb_folder . "/" . $name . ".txt")) {
                    $needs_description_string .= "$name\n";
                }
            } else {
                $mime_type = L\mimeType($pre_resource);
                $mime_parts = explode('/', $mime_type);
                if (in_array($mime_parts[0], ['video', 'audio'])) {
                    $resource['media_type'] = $mime_parts[0];
                }
                if (in_array($mime_type, ["application/zip"])) {
                    $resource['is_compressed'] = true;
                }
                if (in_array($mime_parts[0], ['video', 'image']) ||
                    in_array($mime_type, ["application/pdf",
                    'application/epub+zip', "text/html", "text/plain"])) {
                    $might_have_thumb = true;
                    if ($mime_parts[0] == 'video') {
                        $might_have_animated_thumb = true;
                    }
                }
                if ($find_files_descriptions && !empty($thumb_folder) &&
                    !file_exists($thumb_folder . "/" . $name . ".txt")) {
                    $needs_description_string .= "$name\n";
                }
            }
            if (is_writable($pre_resource)) {
                $resource['is_writable'] = true;
            }
            if (isset($thumbs[$name . ".jpg"]) ||
                isset($thumbs[$name . ".webp"])) {
                $resource['has_thumb'] = true;
                if (isset($thumbs[$name . C\MOVING_THUMB_ENDING])) {
                    $resource['has_animated_thumb'] = true;
                }
            } else if ($thumb_folder && !$resource['is_dir'] &&
                time() < $time + C\PAGE_TIMEOUT/2) {
                $resource['has_thumb'] =
                    $this->makeThumbStripExif($name, $folder, $thumb_folder);
            }
            if ($resource['is_writable'] && ($might_have_animated_thumb &&
                !$resource['has_animated_thumb'])
                || ($might_have_thumb && !$resource['has_thumb'])) {
                $missing_thumb = true;
            }
            $resources[] = $resource;
            if (count($resources) % self::RESOURCE_LIST_YIELD == 0) {
                /* Assembling a large media list reads and stats every file
                   and can take long enough to freeze a single-process
                   server. Give the cooperative loop a turn every so often
                   so other connections keep being served while this runs;
                   outside the cooperative loop this does nothing. */
                if (\Fiber::getCurrent() !== null) {
                    \Fiber::suspend();
                }
            }
        }
        if (!empty($parent_counts_file)) {
            $num_resources = count($resources);
            $parent_counts ??= [];
            $parent_counts[$folder] ??= -1;
            if ($parent_counts[$folder] != $num_resources) {
                $parent_counts[$folder] = $num_resources;
                file_put_contents($parent_counts_file,
                    serialize($parent_counts));
            }
        }
        $missing_name = L\crawlHash($folder) . ".txt";
        if ($missing_thumb && !file_exists(self::NEEDS_THUMBS_DIR .
            "/$missing_name")) {
            if (file_exists(self::NEEDS_THUMBS_DIR)) {
                file_put_contents(self::NEEDS_THUMBS_DIR .
                    "/$missing_name", serialize($folders));
            } else {
                L\makePath(self::NEEDS_THUMBS_DIR);
                clearstatcache();
                if (file_exists(self::NEEDS_THUMBS_DIR)) {
                    file_put_contents(self::NEEDS_THUMBS_DIR .
                        "/$missing_name", serialize($folders));
                }
            }
        }
        $resource_info['resources'] = $resources;
        if ($subfolder_counts_change && !empty($thumb_folder)) {
            file_put_contents($subfolder_counts_file,
                serialize($subfolder_counts));
        }
        if (!empty($needs_descriptions_format) &&
            $needs_descriptions_format != 'no-lookup' &&
            !empty($needs_description_file)) {
 /* record folder missing descriptions in global needs file */
            $needs_description_data = file_exists(self::NEEDS_DESCRIPTION_FILE)?
                file_get_contents(self::NEEDS_DESCRIPTION_FILE) : "";
            if (!str_contains($needs_description_data,
                "$page_id:$thumb_folder")) {
                $needs_description_data .= "$page_id:$thumb_folder\n";
                file_put_contents(self::NEEDS_DESCRIPTION_FILE,
                    $needs_description_data);
            }
            file_put_contents($needs_description_file,
                $needs_description_string);
        }
        return $resource_info;
    }
    /**
     * getGroupPageIconPath returns the file system path where the per-page icon
     * for a wiki page lives on disk.
     * @param int $group_id group the page belongs to
     * @param int $page_id wiki page id
     * @return string absolute path to "page_icon.webp" under the page's thumb
     *     folder (the double extension is the historical on-disk naming)
     */
    public function getGroupPageIconPath($group_id, $page_id)
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id);
        if (!is_array($folders)) {
            return "";
        }
        $thumb_folder = $folders[1] ?? "";
        /* A page's thumbnail is written as page_icon.webp, and asking
           for a thumbnail adds the ending on the way out, so what is
           asked for is the bare name. The two disagreed: the file was
           written with one ending and looked for with two. */
        return $thumb_folder . "/page_icon.webp";
    }
    /**
     * getGroupPageIconUrl returns the URL used by clients to fetch the per-page
     * icon for a wiki page, falling back to a default thumb when the icon does
     * not exist on disk.
     * @param string $csrf_token CSRF token to embed in the URL; empty string
     *     substitutes the "[{rtoken}]" placeholder for later substitution
     * @param int $group_id group the page belongs to
     * @param int $page_id wiki page id
     * @return string fully-qualified URL the browser can request the icon at
     *     (or the default thumb URL when missing)
     */
    public function getGroupPageIconUrl($csrf_token, $group_id, $page_id)
    {
        $page_icon_path = $this->getGroupPageIconPath($group_id, $page_id);
        if (!file_exists($page_icon_path)) {
            $urls = (['default_thumb' => "resources/file-icon.png",
                'default_editable_thumb' => "resources/editable-resource.png",
                'default_folder_thumb' => "resources/folder.png"]);
            return C\SHORT_BASE_URL . $urls['default_thumb'];
        } else {
            $token_string = ($csrf_token) ? C\p('CSRF_TOKEN') . "=" .
                $csrf_token :
                "[{rtoken}]";
            if (C\REDIRECTS_ON) {
                return  C\SHORT_BASE_URL .
                    "wd/thumbs/$token_string/$group_id/$page_id/page_icon.webp";
            } else {
                return C\SHORT_BASE_URL .
                    "?c=resource&amp;a=get&amp;f=resources".
                    "&amp;g=$group_id&amp;p=$page_id" .
                    "&amp;t=thumb&amp;$token_string&amp;n=page_icon.webp";
            }
        }
    }
    /**
     * getGroupPageResourceUrl gives the address a browser fetches one
     * file kept with a wiki page from. The address takes one of two
     * shapes: a path where the site is set to pretty addresses, and a
     * query otherwise, so a caller adding a further field to it picks
     * the separator from what the address already holds.
     *
     * @param string $csrf_token a token that says the request came from
     *     a page this site drew, which guards against a request forged
     *     by another site
     * @param int $group_id which group the page belongs to
     * @param int $page_id which page the file is kept with
     * @param string $resource_name the file's own name
     * @param string $sub_path the folder beneath the page's own folder
     *     that the file sits in, empty where it sits at the top
     * @return string the address, relative to the site's own root
     */
    public function getGroupPageResourceUrl($csrf_token,
        $group_id, $page_id, $resource_name, $sub_path = "")
    {
        $folders = $this->getGroupPageResourcesFolders($group_id, $page_id);
        if (!$folders) {
            return false;
        }
        list($folder, ) = $folders;
        $resource_name = urlencode($resource_name);
        $sub_path = urlencode($sub_path);
        $token_string = ($csrf_token) ? C\p('CSRF_TOKEN') . "=" . $csrf_token :
            "[{rtoken}]";
        if (C\REDIRECTS_ON) {
            $url = C\SHORT_BASE_URL . "wd/resources/$token_string/$group_id/".
                $page_id;
            if ($sub_path) {
                $url .= "/" . urlencode($sub_path) . "/$resource_name";
            } else {
                $url .= "/$resource_name";
            }
        } else {
            $url = C\SHORT_BASE_URL ."?c=resource&amp;a=get&amp;f=resources".
                "&amp;g=$group_id&amp;p=$page_id&amp;n=". $resource_name;
            if (!empty($sub_path)) {
                $url .= "&amp;sf=". $sub_path;
            }
            $url .= "&amp;$token_string";
        }
        return $url;
    }
    /**
     * getGroupPageCount returns the number of non-empty wiki pages a group has
     * (across all locales)
     * @param int $group_id id of group to return the number of wiki pages for
     * @return int number of wiki pages for that group
     */
    public function getGroupPageCount($group_id)
    {
        $sql = "SELECT COUNT(*) AS TOTAL
            FROM GROUP_PAGE WHERE GROUP_ID = ? AND PAGE <> ''";
        $result = $this->db->execute($sql, [$group_id]);
        $row = $this->db->fetchArray($result);
        $total = ($row) ? $row["TOTAL"] : 0;
        return $total;
    }
    /**
     * nextGitIssueNumber works out the number to give the next issue reported
     * against a git repository wiki page, one more than the largest issue
     * number already used for that page. Issue companion pages are named with
     * the page's name, a dollar sign, and the issue number, so the numbers
     * already in use are read from the names of those companion pages.
     * @param int $group_id id of the group the page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @return int the number to give the next issue, starting at one
     */
    public function nextGitIssueNumber($group_id, $page_name, $locale_tag)
    {
        $db = $this->db;
        $prefix = $page_name . C\GIT_ISSUE_SEPARATOR;
        $sql = "SELECT TITLE FROM GROUP_PAGE WHERE GROUP_ID = ? AND
            LOCALE_TAG = ? AND TITLE LIKE ?";
        $result = $db->execute($sql,
            [$group_id, $locale_tag, $prefix . "%"]);
        $highest = 0;
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                $number = $this->gitIssueNumberFromTitle($row["TITLE"],
                    $prefix);
                if ($number > $highest) {
                    $highest = $number;
                }
            }
        }
        return $highest + 1;
    }
    /**
     * gitIssueNumberFromTitle reads the issue number out of a companion page's
     * name, or reports that the name is not one of this page's issue
     * companions. This double-checks the name because the database search that
     * finds companion pages treats an underscore as matching any character, so
     * it can turn up a name that only looks similar.
     * @param string $title the companion page name to read
     * @param string $prefix the git page's name followed by a dollar sign,
     *     which every one of its companion pages starts with
     * @return int the issue number, or zero when the name is not one of this
     *     page's issue companions
     */
    private function gitIssueNumberFromTitle($title, $prefix)
    {
        if (strncmp($title, $prefix, strlen($prefix)) !== 0) {
            return 0;
        }
        $rest = substr($title, strlen($prefix));
        if ($rest === "" || !ctype_digit($rest)) {
            return 0;
        }
        return (int)$rest;
    }
    /**
     * heldGitIssuePage reads one page of the reports waiting for an editor on a
     * git repository page, oldest first, and how many are waiting altogether.
     * Each waiting report is a page of its own beside the repository's, marked
     * by a different separator from a numbered issue so that the two are told
     * apart by name alone.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @param int $start how many reports to skip before this page
     * @param int $num how many reports this page holds
     * @return array a pair of the reports for this page, keyed by the number in
     *     their name, and the total waiting
     */
    public function heldGitIssuePage($group_id, $page_name, $locale_tag,
        $start, $num)
    {
        $db = $this->db;
        $prefix = $page_name . C\GIT_ISSUE_HELD_SEPARATOR;
        $where = "GROUP_ID = ? AND LOCALE_TAG = ? AND TITLE LIKE ?";
        $values = [$group_id, $locale_tag, $prefix . "%"];
        $total = 0;
        $result = $db->execute("SELECT COUNT(*) AS NUM FROM GROUP_PAGE
            WHERE $where", $values);
        if ($result && ($row = $db->fetchArray($result))) {
            $total = intval($row["NUM"]);
        }
        $limit = $db->limitOffset($start, $num);
        $result = $db->execute("SELECT TITLE FROM GROUP_PAGE WHERE $where
            ORDER BY LAST_MODIFIED ASC, ID ASC $limit", $values);
        $reports = [];
        while ($result && ($row = $db->fetchArray($result))) {
            $number = $this->gitIssueNumberFromTitle($row["TITLE"], $prefix);
            if ($number <= 0) {
                continue;
            }
            $record = $this->getHeldGitIssue($group_id, $page_name, $number,
                $locale_tag);
            if ($record !== false) {
                $reports[$number] = $record;
            }
        }
        return [$reports, $total];
    }
    /**
     * getHeldGitIssue reads one report waiting for an editor.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param int $number which waiting report
     * @param string $locale_tag language the page is written for
     * @return array|bool the report, or false if there is no such one
     */
    public function getHeldGitIssue($group_id, $page_name, $number,
        $locale_tag)
    {
        $held_page_name = $page_name . C\GIT_ISSUE_HELD_SEPARATOR . $number;
        $info = $this->getPageInfoByName($group_id, $held_page_name,
            $locale_tag, "edit");
        if (empty($info["PAGE"])) {
            return false;
        }
        $marker = WikiParser::END_HEAD_VARS;
        $position = strpos($info["PAGE"], $marker);
        if ($position === false) {
            return false;
        }
        $record = json_decode(trim(substr($info["PAGE"],
            $position + strlen($marker))), true);
        return is_array($record) ? $record : false;
    }
    /**
     * addHeldGitIssue adds a report to the queue waiting for an editor. When
     * the queue is already at the most it may hold, the report that has waited
     * longest is dropped to make room, so that a run of reports cannot shut the
     * reporting channel to everybody else.
     * @param int $user_id id of whoever the page is written as
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param array $record the report
     * @param string $locale_tag language the page is written for
     * @return int the number the report was given
     */
    public function addHeldGitIssue($user_id, $group_id, $page_name,
        $record, $locale_tag)
    {
        list($waiting, $total) = $this->heldGitIssuePage($group_id,
            $page_name, $locale_tag, 0, 1);
        if ($total >= C\MAX_HELD_GIT_ISSUES) {
            foreach (array_keys($waiting) as $oldest) {
                ($this->deleteGroupPage($group_id, $page_name .
                    C\GIT_ISSUE_HELD_SEPARATOR . $oldest, $locale_tag));
            }
        }
        $number = $this->nextHeldGitIssueNumber($group_id, $page_name,
            $locale_tag);
        $held_page_name = $page_name . C\GIT_ISSUE_HELD_SEPARATOR . $number;
        $page = WikiParser::END_HEAD_VARS . "\n" . json_encode($record);
        $this->setPageName($user_id, $group_id, $held_page_name, $page,
            $locale_tag, "", "", "", "", false, -1);
        return $number;
    }
    /**
     * nextHeldGitIssueNumber works out the number to give the next report added
     * to the queue.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @return int one past the highest number in use
     */
    public function nextHeldGitIssueNumber($group_id, $page_name,
        $locale_tag)
    {
        $db = $this->db;
        $prefix = $page_name . C\GIT_ISSUE_HELD_SEPARATOR;
        $result = $db->execute("SELECT TITLE FROM GROUP_PAGE WHERE
            GROUP_ID = ? AND LOCALE_TAG = ? AND TITLE LIKE ?",
            [$group_id, $locale_tag, $prefix . "%"]);
        $highest = 0;
        while ($result && ($row = $db->fetchArray($result))) {
            $number = $this->gitIssueNumberFromTitle($row["TITLE"], $prefix);
            if ($number > $highest) {
                $highest = $number;
            }
        }
        return $highest + 1;
    }
    /**
     * setGitIssueBan shuts a reporter out of reporting against one repository
     * until a given moment, or for good, by writing a page named for the token
     * that stands for them. The token is all that is in the name; the address
     * it was made from is kept inside the page, where only someone who may edit
     * the repository can read it.
     * @param int $user_id id of whoever the page is written as
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $token the token standing for the reporter
     * @param int $until when they may report again, as a Unix timestamp, or
     *     C\FOREVER for a shutting out that does not lapse
     * @param string $address the address the token was made from
     * @param string $handle the name the reporter gave, so a list of who is
     *     shut out reads as names rather than tokens
     * @param string $locale_tag language the page is written for
     */
    public function setGitIssueBan($user_id, $group_id, $page_name, $token,
        $until, $address, $handle, $locale_tag)
    {
        $ban_page_name = $page_name . C\GIT_ISSUE_BAN_SEPARATOR . $token;
        $record = ["until" => $until, "address" => $address,
            "handle" => $handle, "banned" => time()];
        $page = WikiParser::END_HEAD_VARS . "\n" . json_encode($record);
        $this->setPageName($user_id, $group_id, $ban_page_name, $page,
            $locale_tag, "", "", "", "", false, -1);
    }
    /**
     * getGitIssueBan reads what is known about a reporter being shut out of one
     * repository.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $token the token standing for the reporter
     * @param string $locale_tag language the page is written for
     * @return array|bool when the shutting out ends, the address it was made
     *     from and when it was given, or false if there is none
     */
    public function getGitIssueBan($group_id, $page_name, $token,
        $locale_tag)
    {
        $ban_page_name = $page_name . C\GIT_ISSUE_BAN_SEPARATOR . $token;
        $info = $this->getPageInfoByName($group_id, $ban_page_name,
            $locale_tag, "edit");
        if (empty($info["PAGE"])) {
            return false;
        }
        $marker = WikiParser::END_HEAD_VARS;
        $position = strpos($info["PAGE"], $marker);
        if ($position === false) {
            return false;
        }
        $record = json_decode(trim(substr($info["PAGE"],
            $position + strlen($marker))), true);
        return is_array($record) ? $record : false;
    }
    /**
     * gitIssueBanPage reads one page of the reporters shut out of a repository,
     * together with how many there are altogether. A search narrows the list to
     * those whose name or token carries the words given.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @param string $search words the name or token must carry, empty for all
     *     of them
     * @param int $start how many to skip before this page
     * @param int $num how many this page holds
     * @return array a pair of the records for this page, each with the token it
     *     is named by added, and the total shut out
     */
    public function gitIssueBanPage($group_id, $page_name, $locale_tag,
        $search, $start, $num)
    {
        $db = $this->db;
        $prefix = $page_name . C\GIT_ISSUE_BAN_SEPARATOR;
        $where = "GROUP_ID = ? AND LOCALE_TAG = ? AND TITLE LIKE ?";
        $values = [$group_id, $locale_tag, $prefix . "%"];
        $search = trim($search);
        if ($search !== "") {
            $where .= " AND (LOWER(TITLE) LIKE LOWER(?) OR
                LOWER(PAGE) LIKE LOWER(?))";
            $values[] = "%" . $search . "%";
            $values[] = "%" . $search . "%";
        }
        $total = 0;
        $result = $db->execute("SELECT COUNT(*) AS NUM FROM GROUP_PAGE
            WHERE $where", $values);
        if ($result && ($row = $db->fetchArray($result))) {
            $total = intval($row["NUM"]);
        }
        $limit = $db->limitOffset($start, $num);
        $result = $db->execute("SELECT TITLE FROM GROUP_PAGE WHERE $where
            ORDER BY TITLE ASC $limit", $values);
        $bans = [];
        while ($result && ($row = $db->fetchArray($result))) {
            $token = substr($row["TITLE"], strlen($prefix));
            if ($token === "") {
                continue;
            }
            $record = $this->getGitIssueBan($group_id, $page_name, $token,
                $locale_tag);
            if ($record !== false) {
                $record["token"] = $token;
                $bans[] = $record;
            }
        }
        return [$bans, $total];
    }
    /**
     * isGitIssueBanned whether a reporter is shut out of reporting against a
     * repository at this moment. A shutting out written as C\FOREVER never
     * lapses; any other lapses once its moment has passed.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $token the token standing for the reporter
     * @param string $locale_tag language the page is written for
     * @return bool whether they are shut out right now
     */
    public function isGitIssueBanned($group_id, $page_name, $token,
        $locale_tag)
    {
        $record = $this->getGitIssueBan($group_id, $page_name, $token,
            $locale_tag);
        if ($record === false) {
            return false;
        }
        return $record["until"] == C\FOREVER || $record["until"] > time();
    }
    /**
     * createGitIssue reports a new issue against a git repository wiki page by
     * making its hidden companion page. The companion page holds the issue's
     * record as plain data and carries its own discussion thread, which is
     * where the issue is talked over. The chosen issue number is handed back.
     * @param int $user_id id of the person reporting the issue
     * @param int $group_id id of the group the page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param array $record the issue record to store
     * @param string $locale_tag language the page is written for
     * @param string $thread_title title for the issue's discussion thread
     * @param string $thread_description opening text for that thread
     * @param int $post_time when the issue was reported, as a Unix timestamp,
     *     so an imported issue keeps its original date; 0 uses the current time
     * @param int $issue_number a specific number to give the issue, used when
     *     importing into an empty tracker so numbers line up with the source; 0
     *     takes the next free number
     * @return int the number given to the new issue
     */
    public function createGitIssue($user_id, $group_id, $page_name, $record,
        $locale_tag, $thread_title, $thread_description, $post_time = 0,
        $issue_number = 0)
    {
        $number = ($issue_number > 0) ? $issue_number :
            $this->nextGitIssueNumber($group_id, $page_name, $locale_tag);
        $issue_page_name = $page_name . C\GIT_ISSUE_SEPARATOR . $number;
        $page = WikiParser::END_HEAD_VARS . "\n" . json_encode($record);
        $pubdate = ($post_time > 0) ? $post_time : -1;
        $this->setPageName($user_id, $group_id, $issue_page_name,
            $page, $locale_tag, "", $thread_title,
            $thread_description, "", false, $pubdate);
        return $number;
    }
    /**
     * updateGitIssue saves a changed issue record back onto its hidden
     * companion page. The page already exists, so this replaces its stored
     * record and leaves the issue's discussion thread untouched.
     * @param int $user_id id of the person making the change
     * @param int $group_id id of the group the page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param int $issue_number which issue to save
     * @param array $record the changed issue record
     * @param string $locale_tag language the page is written for
     */
    public function updateGitIssue($user_id, $group_id, $page_name,
        $issue_number, $record, $locale_tag)
    {
        $issue_page_name = $page_name . C\GIT_ISSUE_SEPARATOR .
            $issue_number;
        $page = WikiParser::END_HEAD_VARS . "\n" . json_encode($record);
        $this->setPageName($user_id, $group_id, $issue_page_name,
            $page, $locale_tag, "", "", "", "", false);
    }
    /**
     * getGitIssue reads back the record of one issue reported against a git
     * repository wiki page.
     * @param int $group_id id of the group the page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param int $issue_number which issue to read
     * @param string $locale_tag language the page is written for
     * @return array the issue record, or false when there is no such issue
     */
    public function getGitIssue($group_id, $page_name, $issue_number,
        $locale_tag)
    {
        $issue_page_name = $page_name . C\GIT_ISSUE_SEPARATOR .
            $issue_number;
        $info = $this->getPageInfoByName($group_id, $issue_page_name,
            $locale_tag, "edit");
        if (empty($info["PAGE"])) {
            return false;
        }
        $marker = WikiParser::END_HEAD_VARS;
        $position = strpos($info["PAGE"], $marker);
        if ($position === false) {
            return false;
        }
        $body = substr($info["PAGE"], $position + strlen($marker));
        $record = json_decode(trim($body), true);
        return is_array($record) ? $record : false;
    }
    /**
     * gitIssueWhere builds the part of a query that picks out one repository's
     * issues and narrows them to a filter, together with the values it needs.
     * The pages holding a repository's issues are named for the page with the
     * issue separator and a number after it, so one comparison finds them all
     * and leaves alone the reports waiting to become issues, which carry a
     * separator of their own. Everything a filter asks about is written inside
     * the JSON on the page, so each filter is a comparison against that text:
     * the commas are part of the comparison, since without them a search for
     * reporter seven would also find reporter seventy.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @param string $filter which issues are wanted: all, open, closed,
     *     reported, assigned, marked_fixed, marked_wont_fix, or mine
     * @param int $user_id whose issues mine means
     * @return array a pair of the WHERE clause and its values
     */
    private function gitIssueWhere($group_id, $page_name, $locale_tag,
        $filter, $user_id)
    {
        $prefix = $page_name . C\GIT_ISSUE_SEPARATOR;
        $where = "GROUP_ID = ? AND LOCALE_TAG = ? AND TITLE LIKE ?";
        $values = [$group_id, $locale_tag, $prefix . "%"];
        /* The column searched holds the page as it is shown, in which the
           quotation marks of the record's JSON have been written as
           entities, so the text looked for has to be written the same
           way. */
        $quote = "&quot;";
        $open = "%{$quote}status{$quote}:{$quote}" .
            LW\WikiIssue::STATUS_OPEN . "{$quote}%";
        $closed = "%{$quote}status{$quote}:{$quote}" .
            LW\WikiIssue::STATUS_CLOSED . "{$quote}%";
        $unassigned = "%{$quote}assignee{$quote}:0,%";
        switch ($filter) {
            case "open":
                $where .= " AND PAGE LIKE ?";
                $values[] = $open;
                break;
            case "closed":
                $where .= " AND PAGE LIKE ?";
                $values[] = $closed;
                break;
            case "reported":
                $where .= " AND PAGE LIKE ? AND PAGE LIKE ?";
                $values[] = $open;
                $values[] = $unassigned;
                break;
            case "assigned":
                $where .= " AND PAGE LIKE ? AND PAGE NOT LIKE ?";
                $values[] = $open;
                $values[] = $unassigned;
                break;
            case "marked_fixed":
                $where .= " AND PAGE LIKE ?";
                $values[] = "%{$quote}resolution{$quote}:{$quote}" .
                    LW\WikiIssue::RESOLUTION_FIXED . "{$quote}%";
                break;
            case "marked_wont_fix":
                $where .= " AND PAGE LIKE ?";
                $values[] = "%{$quote}resolution{$quote}:{$quote}" .
                    LW\WikiIssue::RESOLUTION_WONT_FIX . "{$quote}%";
                break;
            case "mine":
                $where .= " AND (PAGE LIKE ? OR PAGE LIKE ?)";
                $values[] = "%{$quote}reporter{$quote}:" .
                    intval($user_id) . ",%";
                $values[] = "%{$quote}assignee{$quote}:" .
                    intval($user_id) . ",%";
                break;
        }
        return [$where, $values];
    }
    /**
     * gitIssuePage reads one page of a repository's issues, newest change
     * first, and how many there are altogether under the same filter. Only the
     * issues on the page asked for are opened, so the work does not grow with
     * the number of issues a repository has.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @param string $filter which issues are wanted
     * @param int $user_id whose issues mine means
     * @param int $start how many issues to skip before this page
     * @param int $num how many issues this page holds
     * @return array a pair of the issue records for this page, keyed by issue
     *     number, and the total number under this filter
     */
    public function gitIssuePage($group_id, $page_name, $locale_tag,
        $filter, $user_id, $start, $num)
    {
        $db = $this->db;
        list($where, $values) = $this->gitIssueWhere($group_id, $page_name,
            $locale_tag, $filter, $user_id);
        $total = 0;
        $result = $db->execute("SELECT COUNT(*) AS NUM FROM GROUP_PAGE
            WHERE $where", $values);
        if ($result && ($row = $db->fetchArray($result))) {
            $total = intval($row["NUM"]);
        }
        $limit = $db->limitOffset($start, $num);
        /* Issues changed in the same second would otherwise be in no
           settled order, and a page taken from an unsettled order can hold
           the same rows as the page before it. The id breaks the tie, so
           one page follows the next. */
        $result = $db->execute("SELECT TITLE, LAST_MODIFIED FROM
            GROUP_PAGE WHERE $where ORDER BY LAST_MODIFIED DESC, ID DESC
            $limit", $values);
        $prefix = $page_name . C\GIT_ISSUE_SEPARATOR;
        $issues = [];
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                $number = $this->gitIssueNumberFromTitle($row["TITLE"],
                    $prefix);
                if ($number <= 0) {
                    continue;
                }
                $record = $this->getGitIssue($group_id, $page_name,
                    $number, $locale_tag);
                if ($record !== false) {
                    $record["last_modified"] = $row["LAST_MODIFIED"];
                    $issues[$number] = $record;
                }
            }
        }
        return [$issues, $total];
    }
    /**
     * getPageList returns a list of applicable wiki pages of a group
     * @param int $group_id of group want list of wiki pages for
     * @param string $locale_tag language want wiki page list for
     * @param string $filter string we want to filter wiki page title by
     * @param string $sort one of name_asc, name_desc, date_asc, date_desc
     *     specifying how the page list should be sorted
     * @param string $limit first row we want from the result set
     * @param string $num number of rows we want starting from the first row in
     *     the result set
     * @param string $category optional category name; when non-empty, restricts
     *     results to wiki pages tagged with this category via the
     *     {{category|name}} tag
     * @return array a pair ($total, $pages) where $total is the total number of
     *     rows that could be returned if $limit and $num not present $pages is
     *     an array each of whose elements is an array corresponding to one
     *     TITLE and the first 100 chars out of a wiki page.
     */
    public function getPageList($group_id, $locale_tag, $filter, $sort, $limit,
        $num, $category = "")
    {
        $db = $this->db;
        $filter_parts = preg_split("/\s+/", $filter);
        $sort_map = [ "name_asc" => "LOWER(GP.TITLE) ASC",
            "name_desc" => "LOWER(GP.TITLE) DESC",
            "modified_asc" => "GP.LAST_MODIFIED ASC",
            "" => "GP.LAST_MODIFIED DESC",
        ];
        $sort_dir = $sort_map[$sort] ?? "LOWER(GP.TITLE) ASC";
        $like = "";
        $params = [$group_id, $locale_tag];
        foreach ($filter_parts as $part) {
            if ($part != "") {
                $like .= " AND LOWER(GP.TITLE) LIKE LOWER(?) ";
                $params[] = "%$part%";
            }
        }
        /* Filing a page under a category writes a row in
           GROUP_PAGE_LINK whose LINK_TYPE_ID is the row in
           PAGE_RELATIONSHIP carrying that category's name, so the pages
           of a category are those with such a row. The name is turned
           into its id once and bound like any other value; a page filed
           under a name nothing has ever been filed under gives no id, and
           the list is then empty rather than the whole group. */
        $additional_tables = "";
        $additional_wheres = "";
        $category_id = false;
        if (!empty($category)) {
            $category_id = $this->getRelationshipId($category);
            $additional_tables = ", GROUP_PAGE_LINK GPL";
            $additional_wheres = " AND GP.ID = GPL.FROM_ID
                AND GPL.LINK_TYPE_ID = ? ";
        }
        /* A repository's issues, the reports waiting to become issues
           and the records of reporters shut out of it are all pages beside
           it, told apart by the mark after its name. None of them belongs
           in a list of a group's pages: the repository page stands for the
           lot. */
        $hide_issue_pages = "";
        foreach ([C\GIT_ISSUE_SEPARATOR, C\GIT_ISSUE_HELD_SEPARATOR,
            C\GIT_ISSUE_BAN_SEPARATOR] as $mark) {
            $hide_issue_pages .= " AND GP.TITLE NOT LIKE '%" . $mark . "%' ";
        }
        if (!empty($category)) {
            $params[] = ($category_id === false) ? 0 : $category_id;
        }
        $sql = "SELECT COUNT(DISTINCT GP.ID) AS TOTAL
            FROM GROUP_PAGE GP $additional_tables WHERE GP.GROUP_ID = ? AND
            GP.LOCALE_TAG= ? AND LENGTH(GP.PAGE) > 0 $like $additional_wheres
            $hide_issue_pages";
        $result = $db->execute($sql, $params);
        if ($result) {
            $row = $db->fetchArray($result);
        }
        $total = (isset($row) && $row) ? $row["TOTAL"] : 0;
        $pages = [];
        if ($total > 0) {
            /* A page filed under one name may hold several links of
               that kind, which the join would give back once each, so
               each page is named once. */
            $sql = "SELECT DISTINCT GP.ID AS ID, GP.TITLE AS PAGE_NAME,
                GP.PAGE AS DESCRIPTION,
                GP.LAST_MODIFIED AS LAST_MODIFIED
                FROM GROUP_PAGE GP $additional_tables WHERE GP.GROUP_ID = ? AND
                GP.LOCALE_TAG= ? AND LENGTH(GP.PAGE) > 0
                $like $additional_wheres $hide_issue_pages
                ORDER BY $sort_dir ".
                $db->limitOffset($limit, $num);
            $result = $db->execute($sql, $params);
            $i = 0;
            if ($result) {
                $separator_len = strlen(WikiParser::END_HEAD_VARS);
                /* A narrow screen shows a shorter title. Yioop
                   marks a narrow screen while answering a request, so
                   a run outside one, such as a command line tool,
                   says nothing about the screen and takes the wider
                   figure. */
                $stretch = (!empty($_SERVER["MOBILE"])) ? 5 : 9;
                $max_title_len = $stretch * C\NAME_TRUNCATE_LEN;
                while ($pages[$i] = $db->fetchArray($result)) {
                    $head_pos = strpos($pages[$i]['DESCRIPTION'],
                        WikiParser::END_HEAD_VARS);
                    if ($head_pos) {
                        $head = substr($pages[$i]['DESCRIPTION'], 0, $head_pos);
                        $pages[$i]['HEADER'] = [];
                        if (preg_match('/page_type\=(.*)/', $head, $matches)) {
                            $pages[$i]['TYPE'] = $matches[1];
                            if (preg_match('/page_alias\=(.+)/', $head,
                                $matches)) {
                                $pages[$i]['ALIAS'] = $matches[1];
                            } elseif ($pages[$i]['TYPE'] == 'page_alias') {
                                $pages[$i]['TYPE'] = "standard";
                            }
                        } else {
                            $pages[$i]['TYPE'] = "standard";
                        }
                        if ($pages[$i]['TYPE'] == 'page_alias') {
                            $pages[$i]['DESCRIPTION'] =
                                $pages[$i]['ALIAS'];
                        } else {
                            list ($pages[$i]['HEADER'],
                                $pages[$i]['DESCRIPTION']) =
                                WikiParser::parsePageHeadVars(
                                    $pages[$i]['DESCRIPTION'], true);
                        }
                    } else {
                        $pages[$i]['TYPE'] = "standard";
                    }
                    $show_description =
                        empty($pages[$i]['HEADER']['description']) ?
                        $pages[$i]["DESCRIPTION"] :
                        $pages[$i]['HEADER']['description'];
                    $ellipsis = (mb_strlen($show_description) >
                        self::MIN_SNIPPET_LENGTH) ? "..." : "";
                    $pages[$i]['SHOW_DESCRIPTION'] = mb_substr(
                        $show_description, 0,
                        self::MIN_SNIPPET_LENGTH) . $ellipsis;
                    $ellipsis = (mb_strlen($pages[$i]["PAGE_NAME"]) >
                        $max_title_len) ? "..." : "";
                    $pages[$i]["SHOW_PAGE_NAME"] = mb_substr(
                        $pages[$i]["PAGE_NAME"], 0,
                        $max_title_len) . $ellipsis;
                    $i++;
                }
                unset($pages[$i]); /* last one will be null */
            }
        }
        return [$total, $pages];
    }
    /**
     * getGroupPageSettings gives the two settings a group keeps for its own
     * pages: the names its pages may be filed under, and the kind of page a new
     * page in it starts as. A group that has said nothing gets an empty list
     * and no kind, which leaves a new page standard as before.
     * @param int $group_id which group to read
     * @return array the names under a categories key and the kind under a
     *     default_page_type key
     */
    public function getGroupPageSettings($group_id)
    {
        $db = $this->db;
        $sql = "SELECT PAGE_CATEGORIES, DEFAULT_PAGE_TYPE FROM
            SOCIAL_GROUPS WHERE GROUP_ID = ?";
        $result = $db->execute($sql, [$group_id]);
        $settings = ["categories" => [], "default_page_type" => ""];
        if (!$result) {
            return $settings;
        }
        $row = $db->fetchArray($result);
        if (!$row) {
            return $settings;
        }
        $named = trim($row['PAGE_CATEGORIES'] ?? "");
        if ($named !== "") {
            foreach (explode(",", $named) as $one) {
                $one = trim($one);
                if ($one !== "") {
                    $settings["categories"][$one] = $one;
                }
            }
        }
        $settings["default_page_type"] = $row['DEFAULT_PAGE_TYPE'] ?? "";
        return $settings;
    }
    /**
     * setGroupPageSettings sets the two settings a group keeps for its own
     * pages. The names are held as one line with commas between, since a group
     * has a handful rather than a table's worth, and each is trimmed so a stray
     * space does not become a name of its own.
     * @param int $group_id which group to set
     * @param array $categories the names its pages may be filed under
     * @param string $default_page_type the kind a new page starts as
     */
    public function setGroupPageSettings($group_id, $categories,
        $default_page_type)
    {
        $db = $this->db;
        $kept = [];
        foreach ($categories as $one) {
            $one = trim($one);
            if ($one !== "" && !in_array($one, $kept)) {
                $kept[] = $one;
            }
        }
        $sql = "UPDATE SOCIAL_GROUPS SET PAGE_CATEGORIES = ?,
            DEFAULT_PAGE_TYPE = ? WHERE GROUP_ID = ?";
        $db->execute($sql, [implode(",", $kept), $default_page_type,
            $group_id]);
    }
    /**
     * getPageCategories gives the names one page is filed under, so a reader
     * can be shown what an article belongs to and follow it to the rest. A page
     * is filed by pointing at itself under the category's name, which is why
     * both ends of the link are the page.
     * @param int $page_id which page to look up
     * @return array the category names, in alphabetical order
     */
    public function getPageCategories($page_id)
    {
        $db = $this->db;
        $sql = "SELECT DISTINCT P.NAME AS NAME FROM PAGE_RELATIONSHIP P,
            GROUP_PAGE_LINK L WHERE L.LINK_TYPE_ID = P.ID
            AND L.FROM_ID = ? AND L.TO_ID = ? ORDER BY P.NAME ASC";
        $result = $db->execute($sql, [$page_id, $page_id]);
        $names = [];
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                if (!empty($row['NAME'])) {
                    $names[] = $row['NAME'];
                }
            }
        }
        return $names;
    }
}
