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

use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\library\av_processing\VideoExtractor;
use seekquarry\yioop\models\MailAccountModel;
use seekquarry\yioop\models\SigninModel;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\MailScheduledDispatcher;
use seekquarry\yioop\library\mail\MailHeaderParser;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\language_processing\PhraseParser;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\library\mail\ImapEnvelopeParser;
use seekquarry\yioop\library\mail\ImapListing;
use seekquarry\yioop\library\mail\ImapFolderListParser;
use seekquarry\yioop\library\mail\ImapResponseParser;
use seekquarry\yioop\library\mail\MimeMessage;
use seekquarry\yioop\library\mail\MailComposeBuilder;
use seekquarry\yioop\views\elements\MailElement;
use seekquarry\yioop\library\media_jobs as LMJ;
use seekquarry\yioop\library\version_control as LVC;
use seekquarry\yioop\library\wiki as LW;
/**
 * WikiComponent draws the wiki screens of Yioop and saves what a user
 * writes on them. A wiki page is a page the members of a group write
 * together.
 *
 * It draws a page for reading and the editor for changing one. It keeps
 * the older versions of a page, so a reader can see what changed and
 * compare two of them. It files a page under the categories it belongs
 * to, and draws the list of pages a group holds.
 *
 * This component extends SocialComponent, the base every component
 * about a group extends. The base holds what more than one of them
 * needs, so no component calls another.
 *
 * AdminController, GroupController and ApiController offer its wiki
 * activity.
 *
 * @author Chris Pollett
 */
class WikiComponent extends SocialComponent
{
    /**
     * PAGES_OFFERED how many of a group's pages are offered to a writer naming
     * what a place on a front page holds
     * @var int
     */
    const PAGES_OFFERED = 200;
    /**
     * LEAD_PARAGRAPHS how many paragraphs of a story a front page shows before
     * offering the rest of it
     * @var int
     */
    const LEAD_PARAGRAPHS = 2;
    /**
     * CATEGORY_TAG how a tag saying what an article is filed under begins,
     * before the category's name and the closing braces
     * @var string
     */
    const CATEGORY_TAG = "{{category|";

    /**
     * wiki handles what somebody asks of a wiki page: reading it,
     * editing it, looking through its history, and putting an
     * earlier version of it back
     * @return array $data an associative array of form variables used to draw
     *     the appropriate wiki page
     */
    public function wiki()
    {
        $parent = $this->parent;
        $controller_name =
            (get_class($parent) == C\NS_CONTROLLERS . "AdminController") ?
                "admin" : "group";
        list($data, $sub_path, $clean_array,
            $strings_array) = $this->initCommonWikiArrays(
                $controller_name);
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if (isset($_SESSION['USER_ID'])) {
            $user_id = $_SESSION['USER_ID'];
            $data['ADMIN'] = 1;
        } else {
            $user_id = C\PUBLIC_USER_ID;
        }
        list($fields, $missing_fields) = $this->cleanWikiRequestFields(
            $clean_array, $strings_array, $controller_name, $user_id);
        $group_id = $fields['group_id'];
        $page_name = $fields['page_name'];
        $page_id = $fields['page_id'] ?? 0;
        $page = $fields['page'] ?? null;
        $data['RESOURCE_FILTER'] = $fields['resource_filter'] ?? "";
        $data['OPEN_IN_TABS'] = empty($_SESSION['OPEN_IN_TABS']) ? false :
            true;
        $data["SHARE_WALL_EDIT"] = false;
        $data['TARGET'] = $fields['target'] ?? "";
        $data["CAN_DELETE"] = false;
        if (!empty($group_id)) {
            if (isset($fields['share_wall_data']) && !empty($page_name)) {
                $page_info = $wiki_model->getPageInfoByName($group_id,
                    $page_name, $data['CURRENT_LOCALE_TAG'], "edit");
                if (!empty($page_info['PAGE']) &&
                    $wiki_model->getPageType($page_info['PAGE']) ==
                    'share') {
                    $page = $fields['share_wall_data'];
                    $page_id = $page_info['ID'];
                    $data["CAN_EDIT"] = true;
                    $data["SHARE_WALL_EDIT"] = true;
                }
            }
        } else if (!empty($page_id)) {
            $page_info = $wiki_model->getPageInfoByPageId($page_id);
            if (isset($page_info["GROUP_ID"])) {
                $group_id = $page_info["GROUP_ID"];
                unset($page_info);
            } else {
                $group_id = C\PUBLIC_GROUP_ID;
            }
        } else {
            $group_id = C\PUBLIC_GROUP_ID;
        }
        if ($group_model->checkUserGroup($user_id,
            $group_id, C\EDITOR_STATUS)) {
            $data['CAN_EDIT'] = true;
            $data['CAN_DELETE'] = true;
        }
        $group = $group_model->getGroupById($group_id, $user_id, true);
        if (!$group) {
            $group = $group_model->getGroupById($group_id, $user_id);
            $data['CAN_EDIT'] = false;
        }
        if (!$group || !isset($group["OWNER_ID"])) {
            if ($data['MODE'] !== 'api') {
                if ($user_id == C\PUBLIC_USER_ID) {
                    $_REQUEST = ['c' => "admin", 'a' => '',
                        C\p('CSRF_TOKEN') => ''];
                    return $parent->redirectWithMessage(
                        tl("social_component_login_first"));
                }
                unset($_REQUEST["route"]);
                $_REQUEST['group_id'] = C\PUBLIC_GROUP_ID;
                return $parent->redirectWithMessage(
                    tl("social_component_no_group_access"), false, false,
                    true);
            } else {
                $data['errors'] =  [];
                $data['errors'][] = tl("social_component_no_group_access");
            }
            $group_id = C\PUBLIC_GROUP_ID;
            $group = $group_model->getGroupById($group_id, $user_id);
        } else {
            if ($group["OWNER_ID"] == $user_id ||
                $group["STATUS"] == C\EDITOR_STATUS ||
                ($group["STATUS"] == C\ACTIVE_STATUS &&
                $group["MEMBER_ACCESS"] == C\GROUP_READ_WIKI)
                && $user_id != C\PUBLIC_USER_ID) {
                $data["CAN_EDIT"] = true;
            }
            if ($group["OWNER_ID"] == $user_id ||
                $group["STATUS"] == C\EDITOR_STATUS) {
                $data["CAN_DELETE"] = true;
            }
        }
        if ($group_id == C\PUBLIC_GROUP_ID) {
            $read_address = "[{controller_and_page}]";
        } else {
            $read_address = htmlentities(B\wikiUrl("", true, '[{controller}]',
                $group_id)) . "[{token}]&page_name=";
        }
        $search_page_info = false;
        if (isset($_REQUEST["arg"])) {
            /* Both podcast requests come in on an address that names
               the page rather than numbering it, so the number is looked
               up here. Built from a page id of zero, the folder key
               named a folder no podcast source fills: every source was
               passed over and a forced update downloaded nothing, while
               the status asked after a folder nothing writes to. */
            if (in_array($_REQUEST["arg"], ["podcast_status",
                "podcast_update"]) && empty($page_id) &&
                !empty($page_name)) {
                $page_id = $wiki_model->getPageId($group_id, $page_name,
                    $data['CURRENT_LOCALE_TAG']);
            }
            switch ($_REQUEST["arg"]) {
                case "podcast_status":
                    $this->outputPodcastStatus($group_id, $page_id,
                        $sub_path);
                    break;
                case "podcast_update":
                    $folder_key = LMJ\PodcastDownloadJob::podcastFolderKey(
                        $group_id, $page_id, $sub_path);
                    LMJ\PodcastDownloadJob::requestPodcastUpdate($folder_key);
                    $this->outputPodcastStatus($group_id, $page_id,
                        $sub_path);
                    break;
                case "deletepage":
                    return $this->deleteWikiPage($data, $group_id,
                        $page_name);
                case "edit":
                    $this->editWiki($data, $user_id, $group_id, $group,
                        $page_id, $page_name, $page, $sub_path,
                        $fields['edit_reason'] ?? null, $missing_fields,
                        $read_address);
                    break;
                case "history":
                    $answer = $this->wikiHistory($data, $user_id,
                        $group_id, $group, $page_id, $fields,
                        $read_address);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "media":
                    $this->mediaWiki($data, $group_id, $page_id,
                        $sub_path);
                    break;
                case "media-detail-edit":
                case "media-detail-read":
                    $this->mediaWikiDetail($data, $group_id, $page_id,
                        $sub_path);
                    break;
                case "ballot":
                    /* A witness has followed the link sent to them and
                       takes their part on their own, without the other
                       witnesses being at a form at the same moment. */
                    $this->takeWitnessPart($data, $user_id, $group_id);
                    break;
                case "pages":
                    $search_page_info = $this->wikiPageList($data,
                        $user_id, $group_id, $group, $sub_path, $fields,
                        $page_name);
                    break;
                case 'relationships':
                    $this->wikiRelationships($data, $group_id, $page_id,
                        $fields, $page_name);
                    break;
                case 'source':
                    $this->wikiSource($data, $group_id, $page_name,
                        $sub_path, $fields);
                    break;
            }
        }
        if (!$page_name) {
            $page_name = tl('social_component_main');
        }
        $data["GROUP"] = $group;
        $read_page_id = $wiki_model->getPageId($group_id, $page_name,
            $data['CURRENT_LOCALE_TAG']);
        if (!empty($read_page_id) &&
            $wiki_model->resourcePathBroken($group_id, $read_page_id)) {
            $data["RESOURCE_PATH_ERROR"] = true;
        }
        if ($data["MODE"] == "history") {
            $this->addWikiHistoryPage($data, $group, $group_id,
                $page_name, $page_id);
        } else if (in_array($data["MODE"], ["api", "read", "edit",
            "media", "source"])) {
            $answer = $this->addWikiReadPage($data, $user_id, $group,
                $group_id, $page_name, $sub_path, $search_page_info);
            if ($answer !== null) {
                return $answer;
            }
        }
        if (!empty($data['PAGE_ID'])) {
            $data['PAGE_HAS_RELATIONSHIPS'] =
                $wiki_model->countPageRelationships($data['PAGE_ID']);
        }
        $this->updateGetWikiImpressionInfo($data, $user_id, $group_id);
        return $data;
    }

    /**
     * deleteWikiPage takes a wiki page away and sends the writer back
     * to the group's list of pages with a line saying what happened.
     *
     * wiki calls this for the deletepage argument. A page goes only
     * where the writer may delete in this group, where the request
     * named a page, and where a language is settled, since a page
     * belongs to one language and the others stay. The writer is sent
     * to the page list either way, so a refusal is seen where the
     * pages are.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the page belongs to
     * @param string $page_name name of the page to take away
     * @return mixed what is handed back to the browser
     */
    private function deleteWikiPage(&$data, $group_id, $page_name)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $_REQUEST["arg"] = "pages";
        if ($data["CAN_DELETE"] && !empty($page_name) &&
            isset($group_id) && !empty($data['CURRENT_LOCALE_TAG'])) {
            if ($wiki_model->deleteGroupPage($group_id, $page_name,
                $data['CURRENT_LOCALE_TAG'])) {
                return $parent->redirectWithMessage(
                    tl('social_component_page_deleted'), ['arg']);
            }
        }
        return $parent->redirectWithMessage(
            tl('social_component_page_delete_error'), ['arg']);
    }
    /**
     * wikiHistory draws what a wiki page has been over time: the list
     * of its versions, one earlier version on its own, the difference
     * between two of them, or the page put back to an earlier one.
     *
     * wiki calls this for the history argument. Which of the four is
     * drawn is settled by the fields the request carries: show names
     * one version, diff1 and diff2 name two, and revert names the one
     * to go back to. A reader who is not signed in sees this only
     * where the group and the page both allow their source to be
     * read.
     *
     * @param array &$data what the page will show, added to here
     * @param int $user_id which reader is asking
     * @param int $group_id which group the page belongs to
     * @param array $group what the model holds about that group
     * @param int $page_id which page the history is of
     * @param array $fields the cleaned fields the request carried
     * @param string $read_address address a link in the page is built
     *     from
     * @return mixed what is handed back to the browser, or null where
     *     the page is drawn as usual
     */
    private function wikiHistory(&$data, $user_id, $group_id, $group,
        $page_id, $fields, $read_address)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $page_name = $fields['page_name'] ?? "";
        $limit = $fields['limit'] ?? 0;
        $show = $fields['show'] ?? null;
        $diff = $fields['diff'] ?? null;
        $diff1 = $fields['diff1'] ?? null;
        $diff2 = $fields['diff2'] ?? null;
        $revert = $fields['revert'] ?? null;
        if (!isset($page_id) || !$page_id) {
            return null;
        }
        if ($user_id == C\PUBLIC_USER_ID) {
            $page_source_allowed =
                !empty($group['PAGE_SOURCE_ALLOWED']);
            if ($page_source_allowed && !empty($page_name)) {
                $current_page_info =
                    $wiki_model->getPageInfoByName($group_id,
                    $page_name, $data['CURRENT_LOCALE_TAG'],
                    "read");
                $page_head = WikiParser::parsePageHeadVars(
                    $current_page_info["PAGE"] ?? "");
                $page_source_allowed =
                    empty($page_head['public_source']) ||
                    $page_head['public_source'] == 'true';
            }
            if (!$page_source_allowed) {
                $parent->web_site->header(
                    "HTTP/1.0 404 Not Found");
                $parent->displayView("nocache", $data);
                \seekquarry\atto\webExit();
            }
        }
        $data["MODE"] = "history";
        $data["PAGE_NAME"] = "history";
        $num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
            $_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
           $_SESSION["MAX_PAGES_TO_SHOW"] :
           C\DEFAULT_ADMIN_PAGING_NUM;
        $default_history = true;
        if (isset($show)) {
            $page_info = $wiki_model->getHistoryPage(
                $page_id, $show);
            if ($page_info) {
                $data["MODE"] = "show";
                $default_history = false;
                $render_engine = $group_model->getRenderEngine(
                    $page_info['GROUP_ID']);
                $data["PAGE_NAME"] = $page_info["PAGE_NAME"];
                $data["PAGE_ID"] = $page_id;
                $data[C\p('CSRF_TOKEN')] =
                    $parent->generateCSRFToken($user_id);
                $history_link = "?c={$data['CONTROLLER']}&".
                    "a=wiki&". C\p('CSRF_TOKEN').'='.
                    $data[C\p('CSRF_TOKEN')].
                    '&arg=history&page_id='.
                    $data['PAGE_ID'];
                $history_header =
                    "<div>&nbsp;</div>".
                    "<div class='black-box back-dark-gray'>".
                    "<div class='float-opposite'>".
                    "<a href='$history_link'>".
                    tl("social_component_back") . "</a></div>".
                    tl("social_component_history_page",
                    $data["PAGE_NAME"], date("c", $show)) .
                    "</div>";
                /* A historical revision never changes, but every
                   crawler view re-parses the whole page on the
                   server. A revision with no resources is instead
                   handed to the browser, which renders it with the
                   ported parser in help.js, so the server does
                   only a string pass. A revision that names
                   resources still renders on the server, since
                   resolving a resource needs the group's files on
                   disk, which the browser cannot reach. */
                $has_resources =
                    (strpos($page_info["PAGE"], "((resource")
                    !== false);
                if ($has_resources) {
                    $parser = new WikiParser($read_address);
                    $parsed = $parser->parse(
                        $page_info["PAGE"],
                        render_engine: $render_engine);
                    $parsed =
                        $wiki_model->insertResourcesParsePage(
                        $group_id, $page_id,
                        $data['CURRENT_LOCALE_TAG'], $parsed,
                        $data[C\p('CSRF_TOKEN')],
                            $data['CONTROLLER']);
                    $data["PAGE"] = $history_header . $parsed;
                } else {
                    $this->renderHistoryInBrowser($data,
                        $page_info, $render_engine,
                        $history_header, $page_id);
                }
                $data["DISCUSS_THREAD"] =
                    $page_info["DISCUSS_THREAD"];
            }
        } else if (!empty($diff) &&
            isset($diff1) && isset($diff2)) {
            $page_info1 = $wiki_model->getHistoryPage(
                $page_id, $diff1);
            $page_info2 = $wiki_model->getHistoryPage(
                $page_id, $diff2);
            $data["MODE"] = "diff";
            $default_history = false;
            $data["PAGE_NAME"] = $page_info2["PAGE_NAME"];
            $data["PAGE_ID"] = $page_id;
            $data[C\p('CSRF_TOKEN')] =
                $parent->generateCSRFToken($user_id);
            $history_link = htmlentities(B\controllerUrl(
                $data['CONTROLLER'],true)) .
                "a=wiki&".C\p('CSRF_TOKEN').'='.
                $data[C\p('CSRF_TOKEN')].
                '&arg=history&page_id='.
                $data['PAGE_ID'];
            $out_diff = "<div>+++ {$data["PAGE_NAME"]}\t".
                "''$diff1''</div>\n";
            $out_diff .= "<div>--- {$data["PAGE_NAME"]}\t".
                "''$diff2''</div>\n";
            /* The browser computes the line-by-line diff from the
               two revisions, so the server builds no subsequence
               table and every visitor, crawler or not, receives
               the same page. */
            $out_diff .= "<div id='wiki-diff-rendered'></div>";
            $this->renderDiffInBrowser($data,
                $page_info2["PAGE"], $page_info1["PAGE"]);
            $data["PAGE"] =
                "<div>&nbsp;</div>".
                "<div class='black-box back-dark-gray'>".
                "<div class='float-opposite'>".
                "<a href='$history_link'>".
                tl("social_component_back") . "</a></div>".
                tl("social_component_diff_page",
                $data["PAGE_NAME"], date("c", $diff1),
                date("c", $diff2)) .
                "</div>" . "$out_diff";
        } else if (isset($revert) && $data["CAN_EDIT"]) {
            $page_info = $wiki_model->getHistoryPage(
                $page_id, $revert);
            if ($page_info) {
                $action = "wikiupdate_".
                    "group=".$group_id."&page=" .
                    $page_info["PAGE_NAME"];
                if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'),
                    $action)) {
                    $data['SCRIPT'] .=
                        "doMessage('<h1 class=\"red\" >".
                        tl('social_component_wiki_edited_elsewhere')
                        . "</h1>');";
                    return null;
                }
                $wiki_model->revertResources($page_id, $group_id,
                    $revert);
                /* A version belongs to the locale it was
                   written in, which need not be the one the
                   person reading the history is in, so the
                   version says which locale it goes back
                   to. Taking the reader's locale instead
                   wrote a page of one language over a page
                   of another. */
                $wiki_model->setPageName($user_id,
                    $group_id, $page_info["PAGE_NAME"],
                    $page_info["PAGE"],
                    $page_info["LOCALE_TAG"] ??
                    $data['CURRENT_LOCALE_TAG'],
                    tl('social_component_page_revert_to',
                    date('c', $revert)), "", "", $read_address);
                /* Back to the history the revert was asked
                   for from, in the locale it was read in. */
                /* What a pretty address already carries is
                   left out of the query it redirects to, and
                   the address it goes to no longer carries
                   it, so the route is set aside and every
                   field written out. */
                unset($_REQUEST['route']);
                $_REQUEST['a'] = "wiki";
                $_REQUEST['arg'] = "history";
                $_REQUEST['page_id'] = $page_id;
                $_REQUEST['page_name'] =
                    $page_info["PAGE_NAME"];
                $_REQUEST['page_locale'] =
                    $page_info["LOCALE_TAG"] ??
                    $data['CURRENT_LOCALE_TAG'];
                return $parent->redirectWithMessage(
                    tl("social_component_page_reverted"),
                    ['a', 'arg', 'page_name', 'page_id',
                    'page_locale']);
            } else {
                return $parent->redirectWithMessage(
                    tl("social_component_revert_error"),
                    ['arg', 'page_name', 'page_id']);
            }
        }
        if (empty($data["DISCUSS_THREAD"])) {
            $page_info = $wiki_model->getPageInfoByPageId(
                $page_id);
            $data["DISCUSS_THREAD"] =
                (empty($page_info) ||
                empty($page_info["DISCUSS_THREAD"])) ? -1 :
                $page_info["DISCUSS_THREAD"];
        }
        if ($default_history) {
            $data["LIMIT"] = $limit;
            $data["RESULTS_PER_PAGE"] = $num;
            list($data["TOTAL_ROWS"], $data["PAGE_NAME"],
                $data["HISTORY"]) =
                $wiki_model->getPageHistoryList($page_id, $limit,
                $num);
            if ((!isset($diff1) || !isset($diff2))) {
                $data['diff1'] = $data["HISTORY"][0]["PUBDATE"]
                    ?? 0;
                $data['diff2'] = $data["HISTORY"][0]["PUBDATE"]
                    ?? 0;
                if (count($data["HISTORY"]) > 1) {
                    $data['diff2'] = $data["HISTORY"][1]["PUBDATE"];
                }
            }
        }
        $data['PAGE_ID'] = $page_id;
    }
    /**
     * wikiPageList draws the list of a group's wiki pages, narrowed
     * to one category or to a name being looked for where the request
     * asks for that.
     *
     * wiki calls this for the pages argument. A group says whether a
     * stranger may see the list at all, and a group may open its
     * pages by category alone, which opens a category's list and
     * leaves the whole list closed. Where a name being looked for
     * matches one page exactly, that page is read instead of listed,
     * which is why the name being read is handed back through
     * $page_name.
     *
     * @param array &$data what the page will show, added to here
     * @param int $user_id which reader is asking
     * @param int $group_id which group the pages belong to
     * @param array $group what the model holds about that group
     * @param string $sub_path folder under the page the request names
     * @param array $fields the cleaned fields the request carried
     * @param string &$page_name name of the page to read, written
     *     here where one name matches exactly
     * @return mixed what the model holds about that one page, or
     *     false where no one page was matched
     */
    private function wikiPageList(&$data, $user_id, $group_id, $group,
        $sub_path, $fields, &$page_name)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $limit = $fields['limit'] ?? 0;
        $filter = empty($fields['filter']) ? "" : $fields['filter'];
        if (isset($fields['page_name'])) {
            $page_name = $fields['page_name'];
        }
        $data["MODE"] = "pages";
        /* A name after the address narrows the list to one
           category. It is the same listing, so it answers to
           the same permission, except that a group may share
           its pages by category alone, which opens this and
           leaves the whole list closed. */
        $data["LIST_CATEGORY"] = trim($sub_path, "/");
        /* The whole list of a group's pages opens only where
           the group says so outright. Saying its articles may
           be read by category opens a category's page and
           leaves this one closed. */
        $shares = $group['PAGE_LIST_ALLOWED'] ?? 0;
        $may_read = ($shares == 1) || ($shares ==
            C\GROUP_OPTION_PAGE_LIST_ARTICLES_SETTING &&
            $data["LIST_CATEGORY"] !== "");
        if ($user_id == C\PUBLIC_USER_ID && !empty($group) &&
            !$may_read) {
            /* A group keeping its list of pages to itself is
               not saying the list is not there, it is saying
               the list is none of a stranger's business, so
               the reader is sent to where the site says that
               and told so in the status. A routed domain says
               it in its own words where it has been given
               them. */
            $parent->redirectLocation(
                B\directUrl("error", true) . "p=403");
            \seekquarry\atto\webExit();
        }
        $num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
            $_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
           $_SESSION["MAX_PAGES_TO_SHOW"] :
           C\DEFAULT_ADMIN_PAGING_NUM;
        array_pop($data['sort_fields']);
        array_pop($data['sort_fields']);
        if (!empty($_REQUEST['sort']) && in_array($_REQUEST['sort'],
            array_keys($data['sort_fields']))) {
            if (!empty($_SESSION['media_sorts']) &&
                count($_SESSION['media_sorts']) > 10) {
                $first_key = array_key_first(
                    $_SESSION['media_sorts']);
                unset($_SESSION['media_sorts'][$first_key]);
            }
            $_SESSION['media_sorts']['pages'] = $_REQUEST['sort'];
        }
        $data['CURRENT_SORT'] = $_SESSION['media_sorts']["pages"]
            ?? "";
        if (isset($page_name)) {
            $data['PAGE_NAME'] = $page_name;
        }
        $data["LIMIT"] = $limit;
        $data["RESULTS_PER_PAGE"] = $num;
        $data["FILTER"] = preg_replace("/\s+/u", " ", $filter);
        $filter = preg_replace("/\s+/u", "_", $filter);
        $search_page_info = false;
        if ($filter != "") {
            $search_page_info = $wiki_model->getPageInfoByName(
                $group_id, $filter, $data['CURRENT_LOCALE_TAG'],
                "read");
        }
        if ($data["LIST_CATEGORY"] !== "") {
            list($data["TOTAL_ROWS"], $data["PAGES"]) =
                $wiki_model->getPageList($group_id,
                $data['CURRENT_LOCALE_TAG'], $filter,
                $data['CURRENT_SORT'], $limit, $num,
                $data["LIST_CATEGORY"]);
            /* Each page is shown with the picture standing
               for it, whose address is worked out here since
               only this side can ask the model for it. */
            $csrf_token = $parent->generateCSRFToken($user_id);
            foreach ($data["PAGES"] as $at => $page) {
                $data["PAGES"][$at]['ICON_URL'] =
                    $wiki_model->getGroupPageIconUrl(
                    $csrf_token, $group_id, $page['ID']);
            }
        } else if (!$search_page_info) {
            list($data["TOTAL_ROWS"], $data["PAGES"]) =
                $wiki_model->getPageList(
                $group_id, $data['CURRENT_LOCALE_TAG'], $filter,
                $data['CURRENT_SORT'], $limit, $num);
        } else {
            $data["MODE"] = "read";
            $page_name = $data["FILTER"];
        }
        return $search_page_info;
    }
    /**
     * wikiRelationships draws the pages that link to a wiki page and
     * the pages it links to, grouped by the kind of link.
     *
     * wiki calls this for the relationships argument. Where the page
     * has links of only one kind that kind is picked without the
     * reader saying so, since there is nothing to choose between. The
     * name of the page is written back through $page_name, since the
     * request may name the page by its number alone.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the page belongs to
     * @param int $page_id which page the links are of
     * @param array $fields the cleaned fields the request carried
     * @param string &$page_name name of the page, written here where
     *     the request named the page by its number
     */
    private function wikiRelationships(&$data, $group_id, $page_id,
        $fields, &$page_name)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $limit = $fields['limit'] ?? 0;
        if (isset($fields['page_name'])) {
            $page_name = $fields['page_name'];
        }
        $data["MODE"] = "relationships";
        $data["PAGE_NAME"] = "related";
        if (empty($page_id)) {
            return;
        }
        $page_info = $wiki_model->getPageInfoByPageId(
            $page_id);
        if (!isset($page_name)) {
            $page_name = empty($page_info['PAGE_NAME']) ? "links" :
                $page_info['PAGE_NAME'];
        }
        $num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
            $_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
            $_SESSION["MAX_PAGES_TO_SHOW"] :
            C\DEFAULT_ADMIN_PAGING_NUM;
        $data["PAGE_ID"] = $page_id;
        $data["PAGE_NAME"] = $page_name;
        $data["DISCUSS_THREAD"] = empty($page_info["DISCUSS_THREAD"]
            ) ? -1 : $page_info['DISCUSS_THREAD'];
        $data["GROUP_ID"] = $page_info["GROUP_ID"];
        $data["LIMIT"] = $limit;
        $data["RESULTS_PER_PAGE"] = $num;
        list($data["TOTAL_ROWS"], $data["RELATIONSHIPS"]) =
            $wiki_model->getRelationshipsToFromPage($page_id,
            $limit, $num);
        //only one relationship so select
        if (count($data["RELATIONSHIPS"]) == 1) {
            $current = current($data["RELATIONSHIPS"]);
            $_REQUEST["reltype"] =
                $current["RELATIONSHIP_TYPE"];
        }
        if (isset($_REQUEST["reltype"])) {
            $rel_type = $parent->clean($_REQUEST["reltype"],
                "string");
            $data["REL-TYPE"] = $rel_type;
            $data["GROUP_ID"] = $group_id;
            //clean up
            if (!empty($page_id)) {
                $page_info = $wiki_model->getPageInfoByPageId(
                    $page_id);
                if (!isset($page_name)) {
                    $page_name = empty($page_info['PAGE_NAME'])
                        ? "rel-types" : $page_info['PAGE_NAME'];
                }
                $num = (isset($_SESSION["MAX_PAGES_TO_SHOW"]) &&
                    $_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
                    $_SESSION["MAX_PAGES_TO_SHOW"] :
                    C\DEFAULT_ADMIN_PAGING_NUM;
                $data["PAGE_ID"] = $page_id;
                $data["PAGE_NAME"] = $page_name;
                $data["DISCUSS_THREAD"] =
                    empty($page_info["DISCUSS_THREAD"] ) ? -1 :
                    $page_info['DISCUSS_THREAD'];
                $data["GROUP_ID"] = $page_info["GROUP_ID"];
                $data["LIMIT"] = $limit;
                $data["RESULTS_PER_PAGE"] = $num;
                list($data["TOTAL_TO_PAGES"],
                    $data["PAGES_THAT_LINK_TO"],
                    $data["TOTAL_FROM_PAGES"],
                    $data["PAGES_THAT_LINK_FROM"]) =
                    $wiki_model->pagesLinkedWithRelationship(
                        $page_id, $data["GROUP_ID"],
                        $data["PAGE_NAME"], $rel_type, $limit,$num);
            }
        }
    }
    /**
     * wikiSource opens a wiki page as the text it was written in,
     * rather than as the page that text draws.
     *
     * wiki calls this for the source argument. Where the browser said
     * where the writing mark and the scroll stood, both are put back,
     * so returning to the source lands where the writer left it. The
     * files kept beside the page are gathered too, since the source
     * screen lists them.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the page belongs to
     * @param string $page_name name of the page to open
     * @param string $sub_path folder under the page the request names
     * @param array $fields the cleaned fields the request carried
     */
    private function wikiSource(&$data, $group_id, $page_name,
        $sub_path, $fields)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $page = $fields['page'] ?? null;
        if (isset($_REQUEST['caret']) &&
           isset($_REQUEST['scroll_top'])
                && !isset($page)) {
            $caret = $parent->clean($_REQUEST['caret'],
                'int');
            $scroll_top = $parent->clean($_REQUEST['scroll_top'],
                'int');
            $data['SCRIPT'] .= "wiki = elt('wiki-page');".
                "if (wiki.setSelectionRange) { " .
                "   wiki.focus();" .
                "   wiki.setSelectionRange($caret, $caret);".
                "} ".
                "wiki.scrollTop = $scroll_top;";
        }
        $data["MODE"] = "source";
        $data["settings"] = (!empty($_REQUEST['settings']));
        $data["resources"] = (!empty($_REQUEST['resources']));
        $page_info = $wiki_model->getPageInfoByName($group_id,
            $page_name, $data['CURRENT_LOCALE_TAG'], 'resources');
        /* if page not yet created than $page_info will be null
           so in the below $page_info['ID'] won't be set.
         */
        if (isset($page_info['ID'])) {
            $data['RESOURCES_INFO'] =
                $wiki_model->getGroupPageResourceUrls($group_id,
                $page_info['ID'], $sub_path);
            $this->addPodcastSourceStatus($data, $group_id,
                $page_info['ID'], $sub_path);
        } else {
            $data['RESOURCES_INFO'] = [];
        }
    }
    /**
     * addWikiHistoryPage puts the page a history screen is about into
     * what the view draws, along with whether its source may be read.
     *
     * wiki calls this once the history argument has settled which
     * version to draw. The page is read for its heading values, since
     * a page may say for itself whether a stranger may read its
     * source, and where it says nothing the group's own answer
     * stands.
     *
     * @param array &$data what the page will show, added to here
     * @param array $group what the model holds about the group
     * @param int $group_id which group the page belongs to
     * @param string $page_name name of the page being read
     * @param int $page_id which page the history is of
     */
    private function addWikiHistoryPage(&$data, $group, $group_id,
        $page_name, $page_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if (!empty($page_id)) {
            $page_info = $wiki_model->getPageInfoByPageId($page_id);
            $page_name = $page_info['PAGE_NAME'] ?? "";
        }
        $page_info = $wiki_model->getPageInfoByName($group_id,
            $page_name, $data['CURRENT_LOCALE_TAG'], 'read');
        $view = $parent->view($data['VIEW']);
        $data["PAGE"] = $page_info["PAGE"] ?? "";
        $parent->parsePageHeadVarsView($view, $data["PAGE_ID"],
            $data["PAGE"]);
        if ($data['MODE'] == "read" || empty($_REQUEST['n'])) {
            $data["PAGE"] = $view->page_objects[$data["PAGE_ID"]];
        }
        $data["HEAD"] = $view->head_objects[$data["PAGE_ID"]];
        $page_public_source = $data["HEAD"]['public_source'] ?? "";
        if ($page_public_source === "") {
            $page_public_source =
                !empty($group['PAGE_SOURCE_ALLOWED']) ? 'true' : 'false';
        }
        if ($page_public_source !== 'true' && empty($data['CAN_EDIT'])) {
            $data['NO_HISTORY_SOURCE'] = true;
        }
    }
    /**
     * addWikiReadPage puts a wiki page and its heading values into
     * what the view draws, and answers a page that stands for another
     * address.
     *
     * wiki calls this for every screen that shows a page rather than
     * a list: reading, editing, the source, the files beside it, and
     * the answer an address asks for. A page written in one language
     * and read in another falls back to the language the site
     * started in. A page that stands for another page, or for an
     * address elsewhere, sends the reader on rather than drawing
     * anything.
     *
     * @param array &$data what the page will show, added to here
     * @param int $user_id which reader is asking
     * @param array $group what the model holds about the group
     * @param int $group_id which group the page belongs to
     * @param string $page_name name of the page being read
     * @param string $sub_path folder under the page the request names
     * @param mixed $search_page_info what the model holds about a
     *     page a name being looked for matched, or false
     * @return mixed what is handed back to the browser where the page
     *     stands for another address, or null where the page is drawn
     */
    private function addWikiReadPage(&$data, $user_id, $group,
        $group_id, $page_name, $sub_path, $search_page_info)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $controller_name = $data["CONTROLLER"];
        // history action might set page, otherwise...
        if (empty($data["PAGE"]) && empty($data['RESOURCE_NAME'])) {
            $data["PAGE_NAME"] = $page_name;
            if (!empty($search_page_info)) {
                $page_info = $search_page_info;
            } else {
                $page_info = $wiki_model->getPageInfoByName($group_id,
                    $page_name, $data['CURRENT_LOCALE_TAG'], $data["MODE"]);
            }
            $data["PAGE"] = $page_info["PAGE"] ?? "";
            $data["PAGE_ID"] = $page_info["ID"] ?? "";
            $data["DISCUSS_THREAD"] = $page_info["DISCUSS_THREAD"] ?? "";
        }
        if (empty($data["PAGE"]) &&
            $data['CURRENT_LOCALE_TAG'] != C\p('DEFAULT_LOCALE')) {
            //fallback to default locale for translation
            $page_info = $wiki_model->getPageInfoByName(
                $group_id, $page_name, C\p('DEFAULT_LOCALE'),
                    $data["MODE"]);
            $data["PAGE"] = $page_info["PAGE"] ?? "";
            $data["PAGE_ID"] = $page_info["ID"] ?? "" ;
            $data["DISCUSS_THREAD"] = $page_info["DISCUSS_THREAD"] ?? "";
        }
        $view = $parent->view($data['VIEW']);
        /* A page made on the spot, such as a category's, has no page
           of its own behind it, so an empty name stands for it rather
           than nothing at all. */
        $page_key = $data["PAGE_ID"] ?? "";
        $parent->parsePageHeadVarsView($view, $page_key,
            $data["PAGE"]);
        $data['page_icon'] = $wiki_model->getGroupPageIconUrl(
            $parent->generateCSRFToken($user_id), $group_id,
            $page_key);
        if ($data['MODE'] == "read" || empty($_REQUEST['n'])) {
            $data["PAGE"] = $view->page_objects[$page_key] ?? "";
        }
        $data["HEAD"] = $view->head_objects[$page_key] ??
            WikiParser::PAGE_DEFAULTS;
        $page_public_source = $data["HEAD"]['public_source'] ?? "";
        if ($page_public_source === "") {
            $page_public_source =
                !empty($group['PAGE_SOURCE_ALLOWED']) ? 'true' : 'false';
        }
        if ($page_public_source !== 'true' && empty($data['CAN_EDIT'])) {
            $data['NO_HISTORY_SOURCE'] = true;
        }
        $data['RENDER_ENGINE'] =
            ($group['RENDER_ENGINE'] == C\MARKDOWN_ENGINE) ?
            "markdown" : "mediawiki";
        if (isset($data["HEAD"]['page_type']) &&
            $data["HEAD"]['page_type'] == 'page_alias' &&
            $data["HEAD"]['page_alias'] != '' &&
            in_array($data['MODE'], ["read", 'api']) &&
            !isset($_REQUEST['noredirect']) ) {
            if ($data['MODE'] == 'api') {
                $controller_name = "api";
            }
            $alias_parts = explode("@", $data["HEAD"]['page_alias']);
            $alias = $alias_parts[0];
            $alias_group_id = $group_id;
            if (!empty($alias_parts)) {
                $alias = $alias_parts[1];
                $alias_group_id = $group_model->getGroupId($alias_parts[0]);
            }
            return $parent->redirectLocation(B\wikiUrl(
                $alias,
                true, $controller_name, $alias_group_id) .
                    C\p('CSRF_TOKEN') .
                '=' . $parent->generateCSRFToken($user_id));
        } else if (isset($data["HEAD"]['page_type']) &&
                $data["HEAD"]['page_type'] == 'url_shortener' &&
                in_array($data['MODE'], ["read"])) {
                $parent->redirectLocation(
                    html_entity_decode($data["HEAD"]['url_shortener']));
        }
        /* A writer previewing a front page asks for the places on it to
           be drawn, since what stands in one is answered from what the
           group holds at this moment and the browser cannot know it. What
           comes back is the drawn place and nothing else. */
        if (!empty($_REQUEST['preview_mark'])) {
            $said = $parent->clean($_REQUEST['preview_mark'], "string");
            $data["FRONT_PAGE_PLACE"] = $this->drawnFrontPageMark(
                $parent, $group_model, $group_id, $data, $user_id,
                $said);
            $data["GROUP_ID_FOR_PLACE"] = $group_id;
            /* A place asked for on its own is answered before the
               reading of a page has put the reader's mark in hand,
               and finishing the addresses in it needs that mark. */
            $data[C\p('CSRF_TOKEN')] =
                $parent->generateCSRFToken($user_id);
            $parent->displayView("api", $data);
            \seekquarry\atto\webExit();
        }
        if ($data['MODE'] == "read") {
            $data['GROUP_STATUS'] = $group['STATUS'];
            $data['JUST_THREAD'] = true;
            if (($data["HEAD"]['page_type'] ?? "") == 'git_repository') {
                $this->initializeGitRepositoryReadMode($data, $group_id,
                    $sub_path);
            } else {
                $tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/",
                    "[{form-hash}]", $data['PAGE'] ?? "");
                $data['FORM_HASH'] = L\crawlAuthHash($tmp_page);
                if (!empty($_POST['CSVFORM']) &&
                    !empty($_POST[C\p('CSRF_TOKEN')])) {
                    $this->processWikiFormData($data, $user_id, $group_id,
                        $sub_path);
                }
                $this->initializeReadMode($data, $user_id, $group_id,
                    $sub_path);
                $data['SCRIPT'] .= "initCvsFormTags();" .
                    "initCategoryLists();";
            }
        } else if (in_array($data['MODE'], ['edit', 'source'])) {
            $this->initializeWikiEditAndSource($data, $group_id,
                $sub_path);
        }
    }
    /**
     * initializeWikiEditAndSource makes ready everything the writing
     * screens need: the page's own settings, the editor, the scroll
     * keeper, and whatever the kind of page being written asks for.
     *
     * addWikiReadPage calls this for the editing screen and for the
     * source screen, which want the same set-up. A page may be one of
     * several kinds, and a kind brings its own controls: a repository
     * shows how to clone it, an address shortener shows how often it
     * was followed, and a page built from a template starts with that
     * template's boxes. It also settles PAGE_LISTS_FILES, which says
     * whether the page being written is one whose contents are the
     * files kept with it. A screen editing one such file has already
     * set that field, since on that screen the page's own settings are
     * not what was read.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the page belongs to
     * @param string $sub_path folder under the page the request names
     */
    private function initializeWikiEditAndSource(&$data, $group_id,
        $sub_path)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        foreach (WikiParser::PAGE_DEFAULTS as $key => $default) {
            $data[$key] = $default;
            if (isset($data["HEAD"][$key])) {
                $data[$key] = $data["HEAD"][$key];
            }
        }
        $data['PAGE_LISTS_FILES'] ??=
            ($data['page_type'] ?? "") == 'media_list';
        $this->initUserResourcePreferences($data);
        $scroll_id = "scroll-container-" .
            L\crawlHash($data['PAGE_ID'] . $sub_path);
        $data['SCROLL_CONTAINER_ID'] = $scroll_id;
        $data['SCRIPT'] .= "initScrollPositionPreserver('".
            $data['SCROLL_CONTAINER_ID'] . "');";
        if ($data['CURRENT_LAYOUT'] == 'detail') {
            $data['DETAIL_SCROLL_ID'] = "detail-$scroll_id";
            $data['SCRIPT'] .= "initScrollPositionPreserver('".
                $data['DETAIL_SCROLL_ID'] . "');";
        }
        if (!empty($data['RESOURCE_NAME'])) {
            $name_parts = pathinfo($data['RESOURCE_NAME']);
            if (!empty($name_parts['extension']) &&
                empty($data['RAW'])) {
                $extension = strtolower($name_parts['extension']);
                if (in_array($extension, C\EDITABLE_IMAGE_EXTENSIONS)) {
                    $data['INCLUDE_SCRIPTS'][] = 'image_editor';
                }
                switch ($name_parts['extension']) {
                    case 'csv':
                        $user_config = "";
                        if (!empty($_SESSION['USER_NAME'])) {
                            $user_config .= ',user_name:'.
                                json_encode($_SESSION['USER_NAME']);
                        }
                        $data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
                        $data['SCRIPT'] .=
                            'spreadsheet = new Spreadsheet(' .
                            '"spreadsheet",' .
                            $data["PAGE"] . ', {mode:"write"'.
                            "$user_config});".
                            'spreadsheet.draw();';
                        $data['SPREADSHEET'] = true;
                        break;
                }
            }
        }
        foreach (['settings', 'resources'] as $field) {
            $data[$field] = "false";
            if (isset($_REQUEST[$field]) &&
                 $_REQUEST[$field] == 'true') {
                $data[$field] = "true";
            }
        }
        /* A group may say what kind of page a new page in it
           starts as, so a newsroom's writer begins with an
           article rather than switching the kind by hand. A page
           that already exists keeps the kind it was saved
           with. */
        if (!empty($data["GROUP"]["GROUP_ID"]) &&
            (empty($data["page_type"]) ||
            ($data["page_type"] == "standard" &&
            empty($data["PAGE_ID"])))) {
            $starts = $wiki_model->getGroupPageSettings(
                $data["GROUP"]["GROUP_ID"])['default_page_type'];
            if (!empty($starts)) {
                $data["page_type"] = $starts;
            }
        }
        $data['current_page_type'] = $data["page_type"];
        $data['anonymous_issue_choices'] = [
            C\GIT_ISSUE_ANON_NONE =>
                tl('social_component_anon_issue_none'),
            C\GIT_ISSUE_ANON_MODERATED =>
                tl('social_component_anon_issue_moderated'),
            C\GIT_ISSUE_ANON_UNMODERATED =>
                tl('social_component_anon_issue_unmoderated'),
        ];
        $data['current_anonymous_issue'] =
            $data["HEAD"]['anonymous_issue_reporting'] ??
            C\GIT_ISSUE_ANON_MODERATED;
        if ($data['current_page_type'] == 'git_repository') {
            $clone_url = C\p('NAME_SERVER') . "group/" . $group_id .
                "/" . $data["PAGE_NAME"] . ".git";
            $data["GIT_CLONE_URL"] = $clone_url;
            if ($data['MODE'] == 'edit' &&
                !empty($data['CAN_EDIT'])) {
                $this->initializeGitAppCode($data, $clone_url);
                $this->initializeGitStatistics($data, $group_id,
                    $data["PAGE_ID"]);
            } else if ($data['MODE'] == 'source' &&
                empty($data['NO_HISTORY_SOURCE'])) {
                $this->initializeGitStatistics($data, $group_id,
                    $data["PAGE_ID"]);
            }
        }
        $data['can_set_static_html_folder'] =
            (!empty($_SESSION['USER_ID']) &&
            $_SESSION['USER_ID'] == C\ROOT_ID);
        if ($data['current_page_type'] == 'url_shortener'
            && $data['MODE'] == 'edit') {
            $this->makeImpressionChart($data, C\WIKI_IMPRESSION,
                C\ONE_DAY, $data['PAGE_ID'], "day_chart",
                "day-chart");
            $this->makeImpressionChart($data, C\WIKI_IMPRESSION,
                C\ONE_MONTH, $data['PAGE_ID'], "month_chart",
                "month-chart");
            $this->makeImpressionChart($data, C\WIKI_IMPRESSION,
                C\ONE_YEAR, $data['PAGE_ID'], "year_chart",
                "year-chart");
        }
        $templates = $group_model->getTemplateMap($group_id,
            $data['CURRENT_LOCALE_TAG']);
        /*
            if the page id is not that of a template, then
            we add the list of templates to the available
            page_type page can be set to and we check
            if page uses a template
         */
        if (empty($templates["t" . $data["PAGE_ID"]])) {
            $data['page_types'] = array_merge($data['page_types'],
                $templates);
            if (empty($_REQUEST['n']) &&
                !empty($templates[$data['current_page_type']])) {
                $template_name = $templates[$data['current_page_type']];
                $template_info = $group_model->
                    getPageInfoByName($group_id, $template_name,
                    $data['CURRENT_LOCALE_TAG'], "read");
                list(, $tmp_page) = WikiParser::parsePageHeadVars(
                    $template_info['PAGE'], true);
                $tmp_page = preg_replace("/{{text\|(.+?)\|(.+?)}}/",
                    "<input type='text' class='narrow-field'" .
                    " name='page[$1]' placeholder='$2'" .
                    " value='{{field|$1}}' >", $tmp_page);
                $tmp_page = preg_replace("/{{area\|(.+?)\|(.+?)}}/",
                    "<textarea class='short-text-area'" .
                    " name='page[$1]' placeholder='$2'>" .
                    "{{field|$1}}</textarea>", $tmp_page);
                if (empty($data['PAGE'])) {
                    $data['PAGE'] = preg_replace(
                        "/{{field\|(.+?)}}/", "", $tmp_page);
                } else {
                    set_error_handler(null);
                    $page_data = @unserialize(base64_decode(
                        $data['PAGE']));
                    restore_error_handler();
                    if (is_array($page_data)) {
                        foreach ($page_data as
                            $page_key => $page_value) {
                            $tmp_page = preg_replace(
                                "/{{field\|" .
                                preg_quote($page_key, "/") .
                                "}}/", $page_value, $tmp_page);
                        }
                    }
                    $data['PAGE'] = preg_replace(
                        "/{{field\|(.+?)}}/", "", $tmp_page);
                }
            }
        }
        $this->initializeWikiPageToggle($data);
        /* A resource opened on its own is edited in the same kind
           of field as a page, so it wants the same editor: without
           it there is no toolbar, and with no toolbar there is
           nowhere for the keeping-as-you-write control to go, so a
           long edit to a text or comma separated file could be
           lost. */
        $this->initializeWikiEditor($data);
    }
    /**
     * processWikiFormData used to process form data associated with a wiki page
     * with a form on it. Such a form's data is stored in a CSV file
     * @param array &$data associative array of values to be echoed by the view
     * @param int $user_id id of user requesting a wiki page
     * @param int $group_id group in which wiki page belongs
     * @param string $sub_path any path within wiki page folder for resources
     * @return mixed redirectWithMessage call result on error/completion paths;
     *     void on early-out (no PAGE_ID / GROUP_ID)
     */
    public function processWikiFormData($data, $user_id, $group_id,
        $sub_path)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $signin_model = $parent->model("signin");
        if (empty($data['PAGE_ID']) || empty($group_id)) {
            return;
        }
        $_REQUEST['SUBMIT_SUCCESSFUL'] = false; /*indicates either not
            submitted or not submitted successfully */
        $default_folders = $wiki_model->getGroupPageResourcesFolders($group_id,
            $data['PAGE_ID']);
        $csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
        $secrets_filepath = $default_folders[0] . '/' .
            C\WIKI_FORM_SECRETS_FILE;
        $preserve_fields =
            ['arg', 'page_name', 'group_name', 'settings', 'caret',
            'scroll_top', 'sf'];
        if (!$parent->checkCSRFToken($_POST[C\p('CSRF_TOKEN')], $user_id,
            true)) {
            return $parent->redirectWithMessage(
                tl('social_component_page_data_expired'), $preserve_fields);
        }
        /* An address that has been tripping the bot checks is held in a
           growing timeout (the same per-IP backoff the account forms use);
           while that timeout is in force, reject the submission outright
           before doing any work. */
        $visitor = $parent->model("visitor")->getVisitor(
            L\remoteAddress(), "captcha_time_out");
        if (isset($visitor['END_TIME']) && $visitor['END_TIME'] > time()) {
            return $parent->redirectWithMessage(
                tl('social_component_captcha_failed'), $preserve_fields);
        }
        $page = $data['PAGE'];
        $tmp_page = preg_replace("/\[{form\-hash(.+?)}\]/", "[{form-hash}]",
            $page);
        $csv_form_hash = L\crawlAuthHash($_POST[C\p('CSRF_TOKEN')] .
            hash("sha256", $tmp_page));
        $secret_form_hash = $data['FORM_HASH'];
        if (!hash_equals($csv_form_hash, $_POST['CSV_FORM_HASH'] ?? "")) {
            return $parent->redirectWithMessage(
                tl('social_component_page_integrity_issue'), $preserve_fields);
        }
        $csv_headers = [];
        if (empty($_POST['CSVFORM']['user_captcha_text']) &&
            empty($_POST['CSVFORM']['require_signin']) ) {
            return $parent->redirectWithMessage(
                tl('social_component_form_needs_captcha'), $preserve_fields);
        }
        /* Two cheap, invisible bot checks. The honeypot is a decoy field
           hidden from people and screen readers, so only an automated
           form-filler puts anything in it. The timing check rejects a
           submission that came back implausibly fast for a person who
           actually read the form. Either trip feeds the per-IP backoff
           and is answered with the same generic message as a failed
           captcha, so a script cannot tell which check caught it. */
        $tripped_bot_check = !empty($_POST[C\WIKI_FORM_HONEYPOT_FIELD]);
        if (!$tripped_bot_check && !empty($_SESSION['request_time']) &&
            time() - $_SESSION['request_time'] < C\MIN_WIKI_FORM_DELAY) {
            $tripped_bot_check = true;
        }
        if ($tripped_bot_check) {
            $parent->model("visitor")->updateVisitor(
                L\remoteAddress(), "captcha_time_out");
            return $parent->redirectWithMessage(
                tl('social_component_captcha_failed'), $preserve_fields);
        }
        $num_fields = count($_POST['CSVFORM'] ?? []);
        if ($num_fields > C\MAX_WIKI_FORM_FIELDS) {
            return $parent->redirectWithMessage(
                tl('social_component_too_many_fields_form'), $preserve_fields);
        }
        $csv_form_fields = $_POST['CSVFORM'] ?? [];
        foreach ($csv_form_fields as $form_field => $field_type) {
            $form_field = substr(
                $parent->clean($form_field, 'string'), 0, C\NAME_LEN);
            if (in_array($field_type, ['submit', 'true'])) {
            } else if (in_array($form_field, $csv_headers)) {
                continue;
            } else {
                $csv_headers[] = $form_field;
            }
        }
        $out_row = [];
        $key_col = ["username" => -1, "user_captcha_text" => -1];
        $i = 0;
        $captcha_hash = L\crawlHash($_SESSION['captcha_text']??
            L\microTimestamp());
        $is_new_hash_row = false;
        $is_load_mode = true;
        $new_data_hash = "";
        $missing_fields = false;
        foreach ($csv_headers as $csv_header) {
            $is_load_col = false;
            $as_sent = str_replace([" ", "."], "_", $csv_header);
            if (!isset($_POST[$csv_header]) && isset($_POST[$as_sent])) {
                $_POST[$csv_header] = $_POST[$as_sent];
            }
            $is_required = (isset($_POST['CSVFORM'][$csv_header]) &&
                str_starts_with($_POST['CSVFORM'][$csv_header], "r-") ) ? 1 : 0;
            if ($is_required &&
                $this->formFieldIsEmpty($_POST[$csv_header] ?? "")) {
                $missing_fields = true;
            }
            if (in_array($csv_header, ["username", "user_captcha_text"])) {
                $key_col[$csv_header] = $i;
            }
            if ($csv_header == 'user_captcha_text') {
                $is_load_col = true;
                $posted_captcha = $_POST[$csv_header] ?? "";
                $keyword_required =
                    !empty($_SESSION['captcha_keyword_required']);
                $is_keyword_ok = !$keyword_required ||
                    (!empty($_SESSION['captcha_text']) &&
                    $posted_captcha === $_SESSION['captcha_text']);
                $is_proof_valid = $parent->meetsProofOfWork(
                    $_SESSION["random_string"] ?? "",
                    $_SESSION["request_time"] ?? "",
                    $_REQUEST['nonce_for_string'] ?? "",
                    $_SESSION["level"] ?? 0);
                $is_human = $is_proof_valid && $is_keyword_ok;
                $is_reload = strlen($captcha_hash) > 0 &&
                    substr($posted_captcha, 0, strlen($captcha_hash)) ==
                    $captcha_hash;
                if (!$is_human && !$is_reload) {
                    $parent->model("visitor")->updateVisitor(
                        L\remoteAddress(), "captcha_time_out");
                    return $parent->redirectWithMessage(
                        tl('social_component_captcha_failed'), array_merge(
                        $preserve_fields, $csv_headers));
                } else if ($is_human && !$is_reload) {
                    $is_new_hash_row = true;
                    $is_load_mode = false;
                }
            }
            $header_type = $_POST['CSVFORM'][$csv_header] ?? "textfield";
            $header_type = ($is_required) ? substr($header_type, 2) :
                $header_type;
            if (in_array($header_type, ['sorter', 'choosek'])) {
                $pre_clean = substr($_POST[$csv_header], 0,
                    C\CVS_FORM_TEXTAREA_LEN);
                $pre_clean = json_decode($pre_clean, true, 2);
                $pre_clean ??= [""];
                if (is_string($pre_clean)) {
                    $pre_clean = [$pre_clean];
                }
                $out_clean = [];
                foreach ($pre_clean as $field => $value) {
                    $out_clean[] = substr($parent->clean($value ?? "",
                        "string"), 0 , C\LONG_NAME_LEN);
                }
                $out_row[] = json_encode($out_clean);
            } else {
                $clean_field = $parent->clean($_POST[$csv_header] ?? "",
                    "string");
                if ($header_type == "submit") {
                    continue;
                } else if ($header_type == "checkbox") {
                    $clean_field = empty($clean_field) ? false : true;
                } else {
                    $max_lengths = [
                        "radio" => C\NAME_LEN,
                        "textfield" => C\LONG_NAME_LEN,
                        "textarea" => C\CVS_FORM_TEXTAREA_LEN
                    ];
                    $clean_field = substr($clean_field, 0,
                        $max_lengths[$header_type]);
                }
                $new_data_hash = L\crawlHash($new_data_hash . $clean_field);
                if (!$is_load_col &&
                    !$this->formFieldIsEmpty($clean_field)) {
                    $is_load_mode = false;
                }
                $out_row[] = $clean_field;
            }
            $i++;
        }
        if ($missing_fields && !$is_load_mode) {
            return $parent->redirectWithMessage(
                tl('social_component_fill_required_fields'), array_merge(
                $preserve_fields, $csv_headers));
        }
        if ($is_new_hash_row) {
            $_REQUEST["user_captcha_text"] = $captcha_hash .
                $new_data_hash;
            $out_row[$key_col["user_captcha_text"]] =
                $_REQUEST["user_captcha_text"];
        } else if ($is_load_mode) {
            $preserve_fields[] = "user_captcha_text";
            return $parent->redirectWithMessage(
                tl('social_component_loading'), $preserve_fields);
        }
        if (file_exists($secrets_filepath) && !file_exists($csv_filepath)) {
            /* A vote holds the answers and the names of what was asked,
               and the two are read back side by side, so they are made
               here from the one list of fields rather than worked out
               twice from what the form sent. The sign-in check and the
               picture puzzle are not questions and are left out of
               both. */
            list($questions, $answers) = $this->voteAnswers($csv_headers,
                $out_row);
            list($message, $receipt) = $signin_model->addVote(
                $secrets_filepath, $secret_form_hash, $questions,
                $answers);
            unset($_REQUEST['route']);
            if ($message == "FILE_CORRUPTED") {
                return $parent->redirectWithMessage(
                    tl('social_component_poll_corrupted'),
                    $preserve_fields);
            } else if ($message == "ALREADY_VOTED") {
                return $parent->redirectWithMessage(
                    tl('social_component_already_voted'),
                    $preserve_fields);
            }
            $_REQUEST['VOTE_RECEIPT'] = $receipt;
            $_REQUEST['SUBMIT_SUCCESSFUL'] = true;
            $_REQUEST['c'] = "group";
            return $parent->redirectWithMessage(
                tl('social_component_vote_cast'), array_merge(
                ['VOTE_RECEIPT', 'SUBMIT_SUCCESSFUL'], $preserve_fields));
        }
        $out_rows = [];
        $active_field = "";
        if (!preg_match(
            "/\[{(share-edited-form|user-record-form|hash-record-form)}\]/",
            $page, $form_matches) && file_exists($csv_filepath)) {
            if (filesize($csv_filepath) > C\MAX_WIKI_FORM_CSV_SIZE) {
                return $parent->redirectWithMessage(
                    tl('social_component_csv_too_big'), $preserve_fields);
            }
            $fh = fopen($csv_filepath, "a+");
        } else if (!empty($form_matches[1]) &&
            $form_matches[1] != 'share-edited-form') {
            $active_field = ($form_matches[1] == 'user-record-form') ?
                "username" : "user_captcha_text";
            if (empty($_POST['CSVFORM']['user_captcha_text']) &&
                $active_field == "user_captcha_text") {
                return $parent->redirectWithMessage(
                    tl('social_component_form_needs_captcha'),
                    $preserve_fields);
            }
            if (file_exists($csv_filepath)) {
                $fh2 = fopen($csv_filepath, "r");
                $csv_headers = fgetcsv($fh2, escape: "\\");
                while ($row = fgetcsv($fh2, escape: "\\")) {
                    if (!empty($row[$key_col[$active_field]]) &&
                        $row[$key_col[$active_field]] ==
                        $_REQUEST[$active_field]) {
                        continue;
                    }
                    $out_rows[] = $row;
                }
                fclose($fh2);
            }
            $fh = fopen($csv_filepath, "w+");
            fputcsv($fh, $csv_headers, escape: "\\");
        } else if (!empty($form_matches[1]) &&
            $form_matches[1] == 'share-edited-form') {
            $kept = [];
            if (file_exists($csv_filepath)) {
                $fh2 = fopen($csv_filepath, "r");
                $kept_headers = fgetcsv($fh2, escape: "\\");
                $kept_row = fgetcsv($fh2, escape: "\\");
                fclose($fh2);
                if (!empty($kept_headers) && !empty($kept_row)) {
                    foreach ($kept_headers as $at => $kept_header) {
                        $kept[$kept_header] = $kept_row[$at] ?? "";
                    }
                }
            }
            foreach ($csv_headers as $at => $csv_header) {
                if (($out_row[$at] ?? "") === "" &&
                    isset($kept[$csv_header])) {
                    $out_row[$at] = $kept[$csv_header];
                }
            }
            $fh = fopen($csv_filepath, "w+");
            fputcsv($fh, $csv_headers, escape: "\\");
        } else {
            $fh = fopen($csv_filepath, "w+");
            fputcsv($fh, $csv_headers, escape: "\\");
        }
        $out_rows[] = $out_row;
        foreach ($out_rows as $out_row) {
            fputcsv($fh, $out_row, escape: "\\");
        }
        fclose($fh);
        $_REQUEST['SUBMIT_SUCCESSFUL'] = true;
        unset($_REQUEST['route']);
        $_REQUEST['c'] = "group";
        /* The route that named the page is dropped just above, so the
           controller, the activity, the group and the page all have to be
           named outright. A page reached by its own address carries none
           of them as fields: without the activity the redirect lands on
           the group controller with nothing to do, and without the group
           and the page it lands on the group's front page rather than on
           the form that was just filled in. */
        $_REQUEST['a'] = "wiki";
        $_REQUEST['group_id'] = $group_id;
        if (!empty($data['PAGE_NAME'])) {
            $_REQUEST['page_name'] = $data['PAGE_NAME'];
        }
        return $parent->redirectWithMessage(
            tl('social_component_choices_recorded'), array_merge(
            ['SUBMIT_SUCCESSFUL'], $preserve_fields, $csv_headers));
    }
    /**
     * renderHistoryInBrowser prepares a wiki history revision to render in the
     * browser instead of on the server. The revision's raw source is placed in
     * a script literal and a short call renders it with the parser ported to
     * help.js, so the server does only a string pass per crawler view. A
     * markdown revision, which has no ported parser, is shown as its raw source
     * with a note. This is used only for revisions with no resources, whose
     * resolution needs the server's files on disk.
     * @param array &$data view fields; PAGE, SCRIPT and INCLUDE_SCRIPTS are set
     * @param array $page_info the revision row, providing PAGE and GROUP_ID
     * @param int $render_engine the group's engine, mediawiki or markdown
     * @param string $header the back link and date shown above the body
     * @param string $page_id id of the page the revision belongs to
     * @return void nothing is handed back
     */
    private function renderHistoryInBrowser(&$data, $page_info, $render_engine,
        $header, $page_id)
    {
        $is_mediawiki = ($render_engine == C\MEDIAWIKI_ENGINE);
        $render_target = "wiki-history-rendered";
        $source_literal = json_encode($page_info["PAGE"],
            JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        if (!isset($data["INCLUDE_SCRIPTS"])) {
            $data["INCLUDE_SCRIPTS"] = [];
        }
        $data["INCLUDE_SCRIPTS"][] = "help";
        $data["INCLUDE_SCRIPTS"][] = "wiki_parser";
        if ($is_mediawiki) {
            $data["PAGE"] = $header . "<div id='$render_target'></div>";
        } else {
            $notice = tl("social_component_history_no_client_render");
            $data["PAGE"] = $header . "<p class='red'>" . $notice .
                "</p><pre id='$render_target'></pre>";
        }
        if (!isset($data['SCRIPT'])) {
            $data['SCRIPT'] = "";
        }
        $data['SCRIPT'] .= "renderWikiHistory('" . $render_target .
            "', $source_literal, " . ($is_mediawiki ? "true" : "false") .
            ", '" . $page_info['GROUP_ID'] . "', '" . $page_id . "', '" .
            $data['CONTROLLER'] . "', '" . C\p('CSRF_TOKEN') . "', '" .
            $data[C\p('CSRF_TOKEN')] . "');\n";
    }
    /**
     * renderDiffInBrowser prepares a wiki-revision diff to be computed in the
     * browser rather than on the server. The two revisions' raw source ride to
     * the page in script literals (angle brackets hex-escaped so a "</script>"
     * inside a revision cannot end the block) and a short call runs the diff
     * ported to help.js. The server emits only the coarse fallback, so it
     * builds no subsequence table on each crawler visit.
     * @param array &$data view fields; SCRIPT and INCLUDE_SCRIPTS are set
     * @param string $source1 source of the first revision being compared
     * @param string $source2 source of the second revision being compared
     * @return void nothing is handed back
     */
    private function renderDiffInBrowser(&$data, $source1, $source2)
    {
        $literal1 = json_encode($source1,
            JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $literal2 = json_encode($source2,
            JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        if (!isset($data["INCLUDE_SCRIPTS"])) {
            $data["INCLUDE_SCRIPTS"] = [];
        }
        $data["INCLUDE_SCRIPTS"][] = "help";
        $data["INCLUDE_SCRIPTS"][] = "wiki_parser";
        if (!isset($data['SCRIPT'])) {
            $data['SCRIPT'] = "";
        }
        $data['SCRIPT'] .= "renderWikiDiff('wiki-diff-rendered', $literal1, " .
            "$literal2, " . C\MAX_DIFF_LCS_CELLS_CLIENT . ", " .
            C\MAX_DIFF_LCS_BAND .
            ");\n";
    }
    /**
     * updateGetWikiImpressionInfo used to populate recent page and group
     * activity dropdowns for a wiki page and to update the recent page
     * impressions so that this can be calculated
     * @param array &$data $data data to be sent to the view, will be modified
     *     according to impression info.
     * @param int $user_id id of the user requesting to change the given wiki
     *     page
     * @param int $group_id id of the group the wiki page belongs to
     */
    private function updateGetWikiImpressionInfo(&$data, $user_id, $group_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $personal_group_id = $group_model->getPersonalGroupId($user_id);
        if (!empty($data['PAGE_ID']) && $data['MODE'] != 'api') {
            $parent->model("impression")->add($user_id, $data['PAGE_ID'],
                C\WIKI_IMPRESSION);
            $parent->model("impression")->add($user_id, $group_id,
                C\GROUP_IMPRESSION);
        }
        if ($user_id != C\PUBLIC_USER_ID) {
            $page_ids = $parent->model("impression")->recent($user_id,
                C\WIKI_IMPRESSION, 6);
            if (!empty($page_ids)) {
                $data['RECENT_PAGES'] = [];
                $i = 0;
                foreach ($page_ids as $recent_page_id) {
                    $page_info = $wiki_model->getPageInfoByPageId(
                        $recent_page_id);
                    $group_name = empty($page_info['GROUP_ID']) ? "" :
                        $group_model->getGroupName($page_info['GROUP_ID']);
                    $len = strlen(C\PERSONAL_GROUP_PREFIX);
                    if (substr($group_name, 0, $len) ==
                        C\PERSONAL_GROUP_PREFIX) {
                        continue;
                    }
                    if (!empty($page_info) && (empty($data['PAGE_NAME']) ||
                        ($page_info['PAGE_NAME'] != $data['PAGE_NAME']))) {
                        $data['RECENT_PAGES'][
                            $page_info['PAGE_NAME']. "@". $group_name] =
                            htmlentities(B\wikiUrl($page_info['PAGE_NAME'],
                            true, $data['CONTROLLER'],
                            $page_info['GROUP_ID']));
                        if ($data['MODE'] == 'edit') {
                            $data['RECENT_PAGES'][$page_info['PAGE_NAME']
                                 . "@" . $group_name] .=
                                "&arg=edit&";
                        }
                    }
                    $i++;
                    if ($i > 5) {
                        break;
                    }
                }
            }
            $group_ids = $parent->model("impression")->recent($user_id,
                C\GROUP_IMPRESSION, 6);
            if (!empty($group_ids)) {
                $data['RECENT_GROUPS'] = [];
                foreach ($group_ids as $recent_group_id) {
                    if ($recent_group_id == $personal_group_id) {
                        continue;
                    }
                    $group_name = $group_model->getGroupName(
                        $recent_group_id);
                    if (!empty($group_name) &&
                        ($recent_group_id != $group_id ||
                        empty($data['PAGE_NAME']) ||
                        $data['PAGE_NAME'] != 'Main')) {
                        $data['RECENT_GROUPS'][$group_name] =
                            htmlentities(B\wikiUrl("Main" , true,
                            $data['CONTROLLER'], $recent_group_id));
                        if ($data['MODE'] == 'edit') {
                            $data['RECENT_GROUPS'][$group_name] .=
                                "&arg=edit&";
                        }
                    }
                }
            }
            if (!empty($data['RECENT_GROUPS']) &&
                count($data['RECENT_GROUPS']) > 5) {
                array_pop($data['RECENT_GROUPS']);
            }
        }
    }
    /**
     * editWiki used to handle edit settings and resources actions for the
     * wiki() activity This method was pulled out of the giant switch case in
     * wiki() and the refactoring still needs some work. Hence, the awkward
     * parameter list below.
     * @param array &$data $data data to be sent to the view, will be modified
     *     according to the edit action.
     * @param int $user_id id of the user requesting to change the given wiki
     *     page
     * @param int $group_id id of the group the wiki page belongs to
     * @param array $group associative array of info about the group wiki page
     *     belongs to
     * @param int $page_id if of wiki page being edited
     * @param string $page_name string name of wiki page being edited
     * @param string $page cleaned wiki page that came from $_REQUEST, if any
     * @param string $sub_path sub resource folder being edited of wiki page, if
     *     any
     * @param string $edit_reason reason for performing update on wiki page
     * @param array $missing_fields fields missing from the request that might
     *     be needed to perform edit
     * @param string $read_address url base addressed to use in performing some
     *     wiki substitutions to generate a html page from a wiki page.
     * @return mixed redirectWithMessage call result on error/success paths;
     *     void on early-out (insufficient privilege or no-op caret-only update)
     */
    private function editWiki(&$data, $user_id, $group_id, $group, $page_id,
        $page_name, $page, $sub_path, $edit_reason,
        $missing_fields, $read_address)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if ($group_model->checkUserGroup($user_id, $group_id,
            C\EDITOR_STATUS)) {
            $data['CAN_EDIT'] = true;
        }
        if (empty($data["CAN_EDIT"])) {
            return;
        }
        if (isset($_REQUEST['caret']) &&
           isset($_REQUEST['scroll_top'])
                && !isset($page)) {
            $caret = $parent->clean($_REQUEST['caret'],
                'int');
            $scroll_top = $parent->clean($_REQUEST['scroll_top'],
                'int');
            $data['SCRIPT'] .= "wiki = elt('wiki-page');" .
                "if (wiki != null) { " .
                "   if (wiki.setSelectionRange) { " .
                "       wiki.focus();" .
                "       wiki.setSelectionRange($caret, $caret);".
                "   } ".
                "   wiki.scrollTop = $scroll_top;" .
                "}";
        }
        $data["MODE"] = "edit";
        /* The editor shows what the page will look like beside the source
           it is written in. The parser ported to help.js does the
           rendering, so a keystroke costs no request and no work on the
           server. That port covers the mediawiki engine only, and a page
           naming a resource needs the group's files on disk to resolve it,
           which the browser cannot reach, so in those two cases the
           preview says so rather than showing something that differs from
           what saving would give. */
        /* Both kinds of page have a reader in the browser now, so both
           show a preview beside their source. */
        $data["WIKI_PREVIEW"] = true;
        /* A page reached by its name arrives without its id, and the
           preview asks the site for a resource by page, so the id is
           looked up here rather than left as nothing. */
        /* A place on a front page may hold a whole category, so the
           names this group files under are offered to choose from: those
           the group has declared, and any its pages are already filed
           under. */
        $kept = $wiki_model->getGroupPageSettings($group_id);
        $data['front_page_categories'] = array_merge($kept['categories'],
            $group_model->getGroupCategories($group_id));
        /* The pages a place may hold, offered as a writer types a name so
           they need not remember what the group holds. */
        list($found, $pages) = $wiki_model->getPageList($group_id,
            $data['CURRENT_LOCALE_TAG'], "", "name_asc", 0,
            self::PAGES_OFFERED);
        $data['front_page_articles'] = [];
        foreach ((array)$pages as $one) {
            $data['front_page_articles'][] = $one['PAGE_NAME'] ??
                ($one['SHOW_PAGE_NAME'] ?? "");
        }
        /* A group may say what kind of page a new page starts as, so a
           newsroom's writer begins with an article rather than switching
           the kind by hand. */
        if (empty($page_id) && !empty($kept['default_page_type']) &&
            (empty($data['current_page_type']) ||
            $data['current_page_type'] == "standard")) {
            $data['current_page_type'] = $kept['default_page_type'];
            $data['page_type'] = $kept['default_page_type'];
        }
        $preview_page_id = $page_id;
        if (empty($preview_page_id) && !empty($page_name)) {
            $preview_page_id = $wiki_model->getPageId($group_id,
                $page_name, $data['CURRENT_LOCALE_TAG'] ??
                L\getLocaleTag());
        }
        $data["WIKI_PREVIEW_PAGE_ID"] = $preview_page_id;
        if ($data["WIKI_PREVIEW"]) {
            $data["INCLUDE_SCRIPTS"] ??= [];
            $data["INCLUDE_SCRIPTS"][] = "help";
            $data["INCLUDE_SCRIPTS"][] = "wiki_parser";
            /* A page written in markdown is read in the browser by the
               reader written for it, which travels on its own so a page
               written in wiki markup need not carry it. */
            if (($group['RENDER_ENGINE'] ?? C\MEDIAWIKI_ENGINE) ==
                C\MARKDOWN_ENGINE) {
                $data["INCLUDE_SCRIPTS"][] = "markdown_parser";
            }
            /* A chart written into a page is drawn by the chart script,
               and at fourteen kilobytes it is not worth fetching only
               when one appears. */
            $data["INCLUDE_SCRIPTS"][] = "chart";
            /* The square symbol is drawn from arithmetic rather than
               fetched, so the editor carries the arithmetic. */
            $data["INCLUDE_SCRIPTS"][] = "qr";
            /* Math is set by MathJax, asked for by name the way every
               other page that shows math asks for it. Fetching it from
               the editor instead put its parts in the wrong order and it
               failed to start. */
            $data["INCLUDE_SCRIPTS"][] = "math";
        }
        /* The group owner and the root account get two extra resource
           actions for fixing the page's resource version history when
           it gets into a bad state: one clears a leftover lock file,
           the other saves a fresh version snapshot of the current
           resource folder. The view adds these after the File Upload
           action so they sit at the end of the list. */
        $data['CAN_FIX_RESOURCE_VERSIONS'] = (isset($group['OWNER_ID']) &&
            $group['OWNER_ID'] == $user_id) || $user_id == C\ROOT_ID;
        if ($data["SHARE_WALL_EDIT"]) {
            $data["MODE"] = "read";
            $_REQUEST['arg'] = 'read';
        }
        $page_info = $wiki_model->getPageInfoByName($group_id,
            $page_name, $data['CURRENT_LOCALE_TAG'], 'edit');
        /* if page not yet created than $page_info will be null
           so in the below $page_info['ID'] won't be set.
         */
        /* What each place was filled in with is read back out of the
           page, so a writer returning to a front page sees their own
           choices. It is read from the page just fetched rather than from
           the fields the form has been given, which are not filled in
           until later and so were always empty here. Whether the page is
           a front page is told from the page itself for the same reason.
           Without this the form came up blank every time and saving wrote
           those blank places back over the page. */
        $held_page = $page_info['PAGE'] ?? "";
        if (strpos($held_page, "front-page-") !== false) {
            $data['front_page_layout'] =
                $this->frontPageLayoutInBody($held_page);
            $data['front_page_slots'] = $this->frontPageSlotChoicesInBody(
                $held_page, $data['front_page_layout']);
            /* A place may name a page that has not been written yet. The
               name is kept either way, and the form says which names lead
               nowhere so a writer can tell a plan from a mistake. */
            foreach ($data['front_page_slots'] as $slot => $filled) {
                if (($filled['kind'] ?? "") != "article") {
                    continue;
                }
                $data['front_page_slots'][$slot]['missing'] =
                    empty($wiki_model->getPageId($group_id,
                    $filled['page'], $data['CURRENT_LOCALE_TAG']));
            }
        }
        $upload_allowed = true;
        /* A page has a copy in each language, so which one is being
           worked on travels through a redirect with everything else.
           Without it a paste or a delete came back on whichever copy
           the site picked rather than the one in front of the
           writer. */
        $preserve_fields = ['arg', 'page_name', 'resources', 'settings',
            'caret', 'scroll_top', 'sf', 'page_locale'];
        if ($missing_fields) {
            return $parent->redirectWithMessage(
                tl("social_component_missing_fields"));
        } else if (isset($page_info['ID']) && !empty($_REQUEST['n'])
            && !isset($_REQUEST['resource_description'])) {
            $answer = $this->editWikiResourceFile($data, $group_id,
                $page_info, $page_name, $page, $sub_path,
                $preserve_fields);
            if ($answer !== null) {
                return $answer;
            }
        } else {
            $answer = $this->editWikiPageSettings($data, $user_id,
                $group_id, $group, $page_name, $page, $sub_path,
                $edit_reason, $read_address, $page_info,
                $preserve_fields);
            if ($answer !== null) {
                return $answer;
            }
        }
        $answer = $this->editWikiResourceAction($data, $user_id, $group_id,
            $group, $page_info, $page_name, $sub_path, $preserve_fields,
            $upload_allowed);
        if ($answer !== null) {
            return $answer;
        }
        $answer = $this->editWikiUploadResource($data, $user_id, $group_id,
            $page_info, $page_name, $page, $sub_path, $edit_reason,
            $read_address, $preserve_fields, $upload_allowed);
        if ($answer !== null) {
            return $answer;
        }
        if (isset($page_info['ID'])) {
            $create = ($user_id == C\PUBLIC_USER_ID) ? false : true;
            $data['RESOURCES_INFO'] =
                $wiki_model->getGroupPageResourceUrls($group_id,
                $page_info['ID'], $sub_path, $create);
            $this->addPodcastSourceStatus($data, $group_id,
                $page_info['ID'], $sub_path);
            if ($user_id != C\PUBLIC_USER_ID) {
                $data['CLIPBOARD_INFO'] =
                    $wiki_model->getClipboardResourceNames($user_id);
            }
        } else {
            $data['RESOURCES_INFO'] = [];
        }
    }

    /**
     * editWikiPageSettings saves what a writer typed for a page,
     * together with the settings they chose for it.
     *
     * editWiki calls this where the request is about the page itself
     * rather than a file beside it. A save is refused where somebody
     * else has written to the page since this writer opened it. An
     * article files itself under the categories the writer picked,
     * and a page built from a template keeps the boxes the template
     * named.
     *
     * @param array &$data what the page will show, added to here
     * @param int $user_id which writer is asking
     * @param int $group_id which group the page belongs to
     * @param array $group what the model holds about that group
     * @param string $page_name name of the page being written
     * @param string $page what the writer typed
     * @param string $sub_path folder under the page the writer is in
     * @param string $edit_reason what the writer said the change is
     *     for
     * @param string $read_address address a link in the page is built
     *     from
     * @param array $page_info what the model holds about the page
     * @param array $preserve_fields fields a redirect carries so the
     *     writer comes back to the screen they were on
     * @return mixed what is handed back to the browser, or null where
     *     the page is drawn as usual
     */
    private function editWikiPageSettings(&$data, $user_id, $group_id,
        $group, $page_name, $page, $sub_path, $edit_reason,
        $read_address, $page_info, $preserve_fields)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        list($head_object, $page_data) = WikiParser::parsePageHeadVars(
            $page_info['PAGE'] ?? "", true);
        $is_currently_template = (!empty($head_object["page_type"]) &&
            $head_object["page_type"][0] == 't');
        if (!$this->canCreateGitRepository($user_id) &&
            ($head_object["page_type"] ?? "") != "git_repository") {
            unset($data["page_types"]["git_repository"]);
        }
        $is_settings = (isset($_REQUEST['settings']) &&
            $_REQUEST['settings'] == 'true');
        if ($is_settings && $page === null &&
            $_SERVER['REQUEST_METHOD'] === 'POST') {
            $page = $page_data;
        }
        if (isset($page) || ($is_currently_template &&
            !empty($_REQUEST['page_type']))
            || isset($_REQUEST['default_sort'])) {
            $action = "wikiupdate_".
                "group=" . $group_id . "&page=" . $page_name;
            if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'), $action) &&
                $head_object["page_type"] != "share") {
                $data['SCRIPT'] .= "doMessage('<h1 class=\"red\" >".
                    tl('social_component_wiki_edited_elsewhere').
                    "</h1>');";
                return;
            }
            list($head_vars, $write_head, $set_path,
                $resource_path_error) = $this->editWikiHeadVars($data,
                $head_object, $page_info, $page, $sub_path);
            /* The resource lookup is offered on a media list alone,
               since no other kind of page reads it. A control put
               away with a style still sends its value, so a page of
               another kind is set to no lookup here rather than
               trusting the form not to send one. */
            if (($head_vars['page_type'] ?? "") != "media_list") {
                $head_vars['update_description'] = "no-lookup";
            }
            /* An article files itself. The names a writer picked are
               written into the body as tags, so that looking a
               category up still finds the page, and into the head as
               the open-graph properties a link preview reads. */
            if (($head_vars['page_type'] ?? "") == "news_article" &&
                is_string($page)) {
                /* Only a screen carrying the category control can
                   change the filing. Saving from a screen without it,
                   such as the settings, left the filing empty and an
                   article dropped out of every category it was in. */
                if (isset($_REQUEST['article_categories'])) {
                    /* The picker says what the writer chose, and any
                       tag typed into the words counts too, so writing
                       one by hand still files the page. */
                    $filed_under = array_values(array_unique(
                        array_merge($this->articleCategories($parent),
                        self::categoriesInBody($page))));
                } else {
                    /* A screen without the picker cannot change the
                       filing, so what the page is already filed under
                       stands. */
                    $filed_under = $wiki_model->getPageCategories(
                        $wiki_model->getPageId($group_id, $page_name,
                        $data['CURRENT_LOCALE_TAG']));
                }
                $page = $this->articleBodyWithCategories($page,
                    $filed_under);
                $head_vars['properties'] = $this->articleProperties(
                    $head_vars, $filed_under, $group_model, $group_id,
                    $page_name, $data['CURRENT_LOCALE_TAG']);
            }
            $head_string = WikiParser::makeWikiPageHead($head_vars);
            if (is_array($page)) { //template case
                $page = base64_encode(serialize($page));
            }
            /* A front page is laid out by choosing a shape and
               naming what fills each of its places, so its body is
               written from those choices rather than typed. */
            if (($head_vars['page_type'] ?? "") == "front_page" &&
                isset($_REQUEST['front_page_layout'])) {
                $page = $this->frontPageBody($parent, $head_vars,
                    $group['RENDER_ENGINE'] ?? C\MEDIAWIKI_ENGINE);
            }
            if (!empty($page) || (!empty($head_vars['page_type']) &&
                $head_vars['page_type'] != 'standard')) {
                $page = $head_string . WikiParser::END_HEAD_VARS . $page;
            }
            $page_info = (empty($page_info)) ? [] : $page_info;
            if (empty($wiki_model->getPageId($group_id, $page_name,
                $data['CURRENT_LOCALE_TAG'])) &&
                $this->overGroupLimit($group_id,
                'MAX_GROUP_WIKI_PAGES',
                $wiki_model->countGroupWikiPages($group_id))) {
                return $parent->redirectWithMessage(
                    tl('social_component_wiki_pages_full'));
            }
            $page_info['ID'] = $wiki_model->setPageName($user_id,
                $group_id, $page_name, $page,
                $data['CURRENT_LOCALE_TAG'], $edit_reason,
                tl('social_component_page_created', $page_name),
                tl('social_component_page_discuss_here'),
                $read_address);
            $preserve_fields[] = 'n';
            if (empty($page_info['ID'])) {
                return $parent->redirectWithMessage(
                    tl('social_component_page_not_saved'),
                    $preserve_fields);
            }
            if (!empty($page_info['ID'])) {
                $page_folders =
                    $wiki_model->getGroupPageResourcesFolders(
                    $group_id, $page_info['ID'], "", true, false);
                if (isset($page_folders[1])) {
                    $page_folder = $page_folders[0];
                    /* Write or remove the resource-path redirect on the
                       page's own folder first, so the folder resolved
                       just below follows the new Resource Path and all
                       of the page's resources, the git repository
                       included, live there. */
                    if ($set_path) {
                        if (!empty($head_vars['alternative_path'])) {
                            $parent->web_site->filePutContents(
                                "$page_folder/redirect.txt",
                                $head_vars['alternative_path']);
                        } else if (file_exists(
                            "$page_folder/redirect.txt")) {
                            unlink("$page_folder/redirect.txt");
                        }
                    }
                    $tmp = $wiki_model->getGroupPageResourcesFolders(
                        $group_id, $page_info['ID'], "", true, true);
                    list($resource_path, $thumb_path,) = $tmp;
                    if (!empty($head_vars['page_type']) &&
                        $head_vars['page_type'] == 'git_repository') {
                        $git_repository =
                            new LVC\GitRepository(
                                $resource_path);
                        $default_branch = preg_replace(
                            "/[^A-Za-z0-9._\/-]/", "",
                            (string)C\p('GIT_DEFAULT_BRANCH'));
                        if ($default_branch === "") {
                            $default_branch =
                                LVC\GitRepository::
                            DEFAULT_BRANCH;
                        }
                        $git_repository->initBare($default_branch);
                    }
                    $csv_form_file = $resource_path . '/' .
                        C\WIKI_FORM_CSV_FILE;
                    if (file_exists($csv_form_file)) {
                        rename($csv_form_file,
                            str_replace( ".csv",
                            date("Y-m-d-H-i-s") . ".csv",
                            $csv_form_file));
                    }
                    $secrets_form_file = $resource_path . '/' .
                        C\WIKI_FORM_SECRETS_FILE;
                    if (file_exists($secrets_form_file)) {
                        rename($secrets_form_file,
                            str_replace( ".txt",
                            date("Y-m-d-H-i-s") . ".txt",
                            $secrets_form_file));
                    }
                }
            }
            if (empty($_FILES['page_resource']['name']) &&
                empty($_FILES['page_icon']['name'])) {
                $saved_message = $resource_path_error ?
                    tl("social_component_resource_not_created") :
                    tl("social_component_page_saved");
                return $parent->redirectWithMessage($saved_message,
                    $preserve_fields);
            } else if (!empty($_FILES['page_icon']['name'])) {
                return $this->handlePageIconUpload($group_id,
                    $page_info['ID'], $preserve_fields);
            }
        }
    }
    /**
     * initCommonWikiArrays used to initialize arrays for dropdowns in
     * WikiElement as well as various arrays for cleaning request variables
     * @param string $controller_name used to set up variables for view elements
     *     should be either admin, api, or group depending on which controller
     *     is being used to handle wiki interaction
     * @return array tuple [$data, $sub_path, $clean_array, ...] of values used
     *     by the wiki action handlers (full destructuring is at the matching
     *     return statement at the end of this function)
     */
    private function initCommonWikiArrays($controller_name)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $data = [];
        $data["CONTROLLER"] = $controller_name;
        $data["ELEMENT"] = "wiki";
        $data["VIEW"] = "group";
        $data["SCRIPT"] = "";
        $data["INCLUDE_STYLES"] = ["editor"];
        $locale_tag = L\getLocaleTag();
        /* A writer may work on a page in a locale other than the one
           they are reading the site in, so the page carries its own
           choice. The site's own language is left as it was. */
        $locale_model = $parent->model("locale");
        $data['LOCALE_LIST'] = $locale_model->getLocaleList();
        if (!empty($_REQUEST['page_locale'])) {
            $wanted = $parent->clean($_REQUEST['page_locale'], "string");
            foreach ($data['LOCALE_LIST'] as $known) {
                if ($known['LOCALE_TAG'] == $wanted) {
                    $locale_tag = $wanted;
                    break;
                }
            }
        }
        $data['CURRENT_LOCALE_TAG'] = $locale_tag;
        $sub_path = "";
        if (!empty($_REQUEST['page_name'])) {
            $name_parts = explode("/", $_REQUEST['page_name']);
            if (count($name_parts) > 1) {
                $_REQUEST['page_name'] = array_shift($name_parts);
                $sub_path = $parent->clean(implode("/", $name_parts),
                    'path');
                $data['SUB_PATH'] = htmlentities($sub_path);
            }
        }
        if (!empty($_REQUEST['sf'])) {
            $sub_path = $parent->clean($_REQUEST['sf'], 'path');
            $data['SUB_PATH'] = htmlentities($sub_path);
        }
        if (!empty($_REQUEST['reset_detail'])) {
            if ($_REQUEST['reset_detail'] == 'true') {
                $data['RESET_DETAIL'] = true;
            }
        }
        $data['ORIGINAL_SUB_PATH'] = $sub_path;
        if ((isset($_REQUEST['c'])) && $_REQUEST['c'] == "api") {
            //wiki help request
            $data['MODE'] = 'api';
            $data['VIEW'] = 'api';
        } else {
            $data["MODE"] = "read";
            // additional feed data on page_and_feedback page
            if (!empty($_REQUEST['f']) && $_REQUEST['f'] == "api") {
                $data['VIEW'] = 'api';
            }
        }
        $data['page_types'] = [
            "standard" => tl('social_component_standard_page'),
            "page_and_feedback" => tl('social_component_page_and_feedback'),
            "page_alias" => tl('social_component_page_alias'),
            "media_list" => tl('social_component_media_list'),
            "presentation" => tl('social_component_presentation'),
            "url_shortener" => tl('social_component_url_shortener'),
            "share" => tl('social_component_share_wall'),
            "git_repository" => tl('social_component_git_repository'),
            "front_page" => tl('social_component_front_page'),
            "news_article" => tl('social_component_news_article')
        ];
        /* A front page lays its feeds out in one of a few settled ways.
           A writer picks one by the look of it, and the page is written
           with the classes that give that look. */
        /* A place on a front page holds either one article or a whole
           category of them, so a writer says which before naming it. */
        $data['front_page_kinds'] = [
            "article" => tl('social_component_one_article'),
            "category" => tl('social_component_a_category'),
            "category_names" => tl('social_component_category_names')
        ];
        $data['front_page_slot_names'] = $this->frontPageSlotNames();
        /* Which arrangement is written first, so the one list of pages a
           writer may choose from is drawn once rather than once per
           arrangement. */
        $data['front_page_layout_first'] = "lead-and-list";
        $data['front_page_layouts'] = [
            "lead-and-list" => tl('social_component_lead_and_list'),
            "cards" => tl('social_component_cards'),
            "grid" => tl('social_component_grid'),
            "sections" => tl('social_component_sections')
        ];
        $data['page_borders'] = [
            "solid-border" => tl('social_component_solid'),
            "dashed-border" => tl('social_component_dashed'),
            "none" => tl('social_component_none')
        ];
        $page_themes = $parent->model('profile')->getThemeNames();
        $data['page_themes'] = array_merge(
            ["" => tl('social_component_no_auxiliary_theme')],
            array_combine($page_themes , $page_themes));
        $data['update_descriptions'] = [
            "no-lookup" => tl('social_component_no_lookup'),
            "files-only" => tl('social_component_files_only'),
            "folders-only" => tl('social_component_folders_only'),
            "files-and-folders" => tl('social_component_files_and_folders')
        ];
        $data['resource_actions'] = [
            tl('social_component_actions') => "",
            "new-folder" => tl('social_component_new_folder'),
            "new-text-file" => tl('social_component_new_text_file'),
            "new-csv-file" => tl('social_component_new_csv_file'),
            "new-image-file" => tl('social_component_new_image_file'),
        ];
        $data['share_page_expires'] = [
            C\FOREVER => tl('social_component_never'),
            C\ONE_HOUR => tl('social_component_one_hour'),
            C\ONE_DAY => tl('social_component_one_day'),
            C\ONE_MONTH => tl('social_component_one_month'),
        ];
        $data['sort_fields'] = [
            tl('social_component_sort_order') => "",
            "name_asc" => tl('social_component_name_ascending'),
            "name_desc" => tl('social_component_name_descending'),
            "modified_asc" => tl('social_component_date_ascending'),
            "modified_desc" => tl('social_component_date_descending'),
            "size_asc" => tl('social_component_size_ascending'),
            "size_desc" => tl('social_component_size_descending'),
        ];
        $clean_array = [
            "group_id" => "int",
            "page_name" => "string", //up to here are required fields
            "diff" => 'int',
            "diff1" => 'int',
            "diff2" => 'int',
            "edit_reason" => "string",
            "filter" => 'string',
            "group_name" => 'string',
            "limit" => 'int',
            "num" => 'int',
            "page" => "string",
            "page_id" => 'int',
            'page_theme' => 'string',
            'resource_description' => '',
            'resource_filter' => 'file_name',
            "revert" => 'int',
            "share_expires" => 'string',
            "share_wall_data" => 'string',
            "show" => 'int',
            "sort" => 'string',
            "target" => "string",
        ];
        $strings_array = [
            "page_name" => C\TITLE_LEN,
            "page" => C\MAX_GROUP_PAGE_LEN,
            "edit_reason" => C\SHORT_TITLE_LEN,
            "filter" => C\SHORT_TITLE_LEN,
            "resource_filter" => C\SHORT_TITLE_LEN];
       /* Check if back params need to be set. Set them if required.
          the back params are usually sent when the wiki action is initiated
          from within an open help article.
        */
        $data["OTHER_BACK_URL"] = "";
        if (isset($_REQUEST['back_params']) &&
            ((isset($_REQUEST['arg']) && in_array(
                $parent->clean($_REQUEST['arg'],"string"), ['edit',
                'read'])) || (isset($_REQUEST['page_name'])))
                ) {
            $back_params_cleaned = $_REQUEST['back_params'];
            array_walk($back_params_cleaned, [$parent, 'clean']);
            foreach ($back_params_cleaned as
                    $back_param_key => $back_param_value) {
                $data['BACK_PARAMS']["back_params[$back_param_key]"]
                    = $back_param_value;
                $data["OTHER_BACK_URL"] .=
                    "&back_params[$back_param_key]" . "=" .
                    $back_param_value;
            }
            $data['BACK_URL'] = http_build_query($back_params_cleaned);
        }
        return [$data, $sub_path, $clean_array,
            $strings_array];
    }
    /**
     * frontPageLayoutInBody reads which arrangement of places a front page uses
     * /** Reads which arrangement of places a front page uses, out of the page
     * itself. The arrangement is not kept anywhere else, so without reading it
     * back a writer returning to a page was offered the first arrangement
     * whatever they had chosen.
     * @param string $body the front page's own markup
     * @return string the arrangement it uses
     */
    public function frontPageLayoutInBody($body)
    {
        foreach (["lead-and-list", "cards", "grid", "sections"] as $one) {
            if (strpos($body, "front-page-" . $one) !== false) {
                return $one;
            }
        }
        return "lead-and-list";
    }
    /**
     * frontPageSlotChoicesInBody reads back what each place on a front page was
     * filled in with, so the form shows a writer what they set rather than a
     * row of empty controls. The page's own body is what says it, since that is
     * where the choices were written. A place naming an article that does not
     * exist keeps its name here as anywhere else: the writer meant that name,
     * and losing it on every visit meant a front page could not be built before
     * the pages it points at were written.
     * @param string $body the front page's own markup
     * @param string $layout which arrangement of places it uses
     * @return array for each place, what kind it holds and what it names
     */
    public function frontPageSlotChoicesInBody($body, $layout)
    {
        $filled = [];
        foreach (array_keys($this->frontPageSlotNames($layout)) as $place) {
            $classes = "front-page-" . $layout . " front-page-" . $place;
            $quoted = preg_quote($classes, "/");
            /* A page is kept as the markup a writer typed on the way in
               and as the drawing it becomes once saved, so both are read
               here: whichever the page is holding, the choices are the
               same. What a place was set to may be written by name before
               its classes, so anything up to the mark's close is allowed
               between them. */
            if (preg_match('/\[\{category-list\|([^|}]*)\|[^\}]*' .
                $quoted . '[^\}]*/', $body, $found) ||
                preg_match('/\{\{category-list\|([^|}]*)\|[^\}]*' .
                $quoted . '[^\}]*/', $body, $found) ||
                preg_match('/class=.[^\'"]*' . $quoted .
                '[^\'"]*.[^>]*data-category=.([^\'"]*)./', $body,
                $found)) {
                $shown = "scroll";
                if (preg_match('/num=(\d+)/', $found[0], $counted)) {
                    $shown = (int)$counted[1];
                }
                $filled[$layout . "-" . $place] = ["kind" => "category",
                    "category" => trim($found[1]), "page" => "",
                    "items" => $shown,
                    "heading" => (strpos($found[0], "heading=") !== false),
                    "shape" => preg_match('/shape=(\w+)/', $found[0],
                    $how) ? $how[1] : "standard"];
                continue;
            }
            if (preg_match('/\[\{category-names\|[^\}]*' . $quoted .
                '[^\}]*/', $body, $found) ||
                preg_match('/\{\{category-names\|[^\}]*' . $quoted .
                '[^\}]*/', $body, $found)) {
                $filled[$layout . "-" . $place] =
                    ["kind" => "category_names", "page" => "",
                    "category" => ""];
                continue;
            }
            /* A place holding one story is kept as a mark naming it,
               answered when the page is read, so the name is here even
               where no page of that name has been written yet. */
            if (preg_match('/\[\{lead-story\|([^|}]*)\|' . $quoted .
                '[^\}]*/', $body, $found) ||
                preg_match('/\{\{lead-story\|([^|}]*)\|' . $quoted .
                '[^\}]*/', $body, $found)) {
                $filled[$layout . "-" . $place] = ["kind" => "article",
                    "page" => trim($found[1]), "category" => "",
                    "paragraphs" => self::paragraphCountInMark($body,
                    $found[0])];
                continue;
            }
            $said = "";
            if (preg_match('/\{\{class="' . $quoted .
                '"\s*\n(.*?)\n\}\}/s', $body, $found)) {
                $said = trim($found[1]);
            } else if (preg_match('/<div class="' . $quoted .
                '[^"]*">(.*?)<\/div>/s', $body, $found)) {
                $said = trim($found[1]);
            }
            if ($said === "" || strpos($said, "front-page-undefined") !==
                false) {
                continue;
            }
            $named = "";
            if (preg_match('/\[\[(.*?)\]\]/', $said, $link)) {
                $named = trim($link[1]);
            } else if (preg_match('/\[(.*?)\]\(/', $said, $link)) {
                $named = trim($link[1]);
            } else if (preg_match('/<a[^>]*>(.*?)<\/a>/s', $said,
                $link)) {
                $named = trim(strip_tags($link[1]));
            }
            if ($named !== "") {
                $filled[$layout . "-" . $place] = ["kind" => "article",
                    "page" => $named, "category" => ""];
            }
        }
        return $filled;
    }
    /**
     * frontPageBody writes the body of a front page from the shape a writer
     * chose and the names they gave its places. A place naming a lead story
     * holds a link to that page; the rest hold a list of what is filed under
     * the category named, laid out by the classes that shape belongs to. The
     * body stays ordinary wiki markup, so anyone can read or edit it without
     * the form. The body is written in whichever markup the group reads. A
     * heading and a link to a page are spelt differently in the two, and a body
     * written in the other one shows its own markup to a reader rather than a
     * heading: a group set to markdown was given a wiki heading and drew the
     * equals signs. What wraps a place in its classes, and what asks for a
     * category's articles, read the same either way.
     * @param object $parent controller that called this, used to clean what was
     *     typed
     * @param array $head_vars the page's settings, which say the layout
     * @param int $render_engine which markup the group reads, wiki or markdown
     * @return string the body of the page
     */
    public function frontPageBody($parent, $head_vars, $render_engine)
    {
        $is_markdown = ($render_engine == C\MARKDOWN_ENGINE);
        $layout = trim($parent->clean($_REQUEST['front_page_layout'],
            "string"));
        $allowed = ["lead-and-list", "cards", "grid", "sections"];
        if (!in_array($layout, $allowed)) {
            $layout = "lead-and-list";
        }
        $slots = $_REQUEST['front_page_slot'] ?? [];
        $names = $this->frontPageSlotNames($layout);
        $body = "";
        foreach ($names as $place => $says) {
            $slot = $layout . "-" . $place;
            $held = $slots[$slot] ?? [];
            /* A field's value arrives with the line ending that closed
               it in the form, so what is compared here is trimmed: an
               untrimmed kind never matched and every place fell back to
               holding a category. */
            $kind = trim($parent->clean($held['kind'] ?? "category",
                "string"));
            $said = ($kind == "article") ? ($held['page'] ?? "") :
                ($held['category'] ?? "");
            $named = trim($parent->clean($said, "string"));
            $classes = "front-page-" . $layout . " front-page-" . $place;
            if ($named == "") {
                /* A place left empty says so where it stands, rather
                   than leaving a gap a writer cannot account for. The
                   words are the ones beside the control they would have
                   filled in, so the page names the very place. */
                $undefined = tl('social_component_slot_undefined',
                    rtrim($says, ": "));
                $heading = $is_markdown ? "# " . $undefined :
                    "=" . $undefined . "=";
                $body .= "{{class=\"" . $classes .
                    " front-page-undefined\"\n" . $heading . "\n}}\n\n";
                continue;
            }
            if ($kind == "article") {
                /* A place holding one article shows the beginning of it
                   with a way through to the rest, rather than a bare
                   link: a reader should be able to tell from the front
                   page whether the story is for them. */
                $paragraphs = max(1, (int)($parent->clean(
                    $held['paragraphs'] ?? self::LEAD_PARAGRAPHS, "int")));
                $body .= "{{lead-story|" . $named . "|" . $classes .
                    "|" . $paragraphs . "}}\n\n";
                continue;
            }
            if ($kind == "category_names") {
                $body .= "{{category-names|" . $classes . "}}\n\n";
                continue;
            }
            /* How many of a category to list, and whether to head the
               list with the category's name, are said by name so a place
               set one way is not disturbed by the other. A count of none
               means the list runs on as a reader reaches its end. */
            $shown = $parent->clean($held['items'] ?? "", "string");
            $asked = "classes=" . $classes;
            if (trim($shown) !== "" && trim($shown) != "scroll") {
                $asked .= "|num=" . max(1, (int)$shown) . "|scroll=no";
            }
            if (!empty($held['heading'])) {
                $asked .= "|heading=" . $named;
            }
            $shape = trim($parent->clean($held['shape'] ?? "", "string"));
            if ($shape !== "" && $shape != "standard") {
                $asked .= "|shape=" . $shape;
            }
            $body .= "{{category-list|" . $named . "|" . $asked .
                "}}\n\n";
        }
        return $body;
    }
    /**
     * drawnFrontPageMark draws one of the marks a front page holds    /** Draws
     * one of the marks a front page holds, given as it was written. A place
     * naming a story becomes the opening of that story; a place naming a
     * category becomes its list. Anything else comes back empty, so a mark this
     * does not know cannot put stray text on a page.
     * @param object $parent controller that called this
     * @param object $group_model model used to look pages up
     * @param int $group_id which group the page belongs to
     * @param array $data fields from the controller
     * @param int $user_id who is reading, so links carry their token
     * @param string $said the mark as a writer wrote it
     * @return string the drawn place
     */
    private function drawnFrontPageMark($parent, $group_model, $group_id,
        $data, $user_id, $said)
    {
        $said = trim($said);
        $inside = trim(substr($said, 2, max(0, strlen($said) - 4)));
        $parts = explode("|", $inside);
        $which = trim(array_shift($parts));
        $named = trim(array_shift($parts) ?? "");
        if ($which == "lead-story") {
            $classes = trim($parts[0] ?? "");
            $paragraphs = (int)trim($parts[1] ?? "0");
            return $this->leadStoryMarkup($parent, $group_model, $group_id,
                $data, $user_id, $named, $classes, $paragraphs);
        }
        if ($which == "category-list") {
            return $this->categoryListMarkup($parent, $group_model,
                $group_id, $data, $user_id, $named, implode("|", $parts));
        }
        if ($which == "category-names") {
            return $this->categoryNamesMarkup($parent, $group_model,
                $group_id, $data, $user_id, $named);
        }
        return "";
    }
    /**
     * groupAndCategory reads a category that may name a group before it,
     * written the way a page in another group is written: group@category. A
     * name with no group before it belongs to the group the page is in.
     * @param string $said the category as a writer wrote it
     * @param int $group_id which group the page is in
     * @param object $group_model model used to look a group up by name
     * @return array the group to ask and the category to ask for, the group
     *     being zero where the name is of no group
     */
    public static function groupAndCategory($said, $group_id, $group_model)
    {
        $at = strpos($said, "@");
        if ($at === false) {
            return [$group_id, $said];
        }
        $named = trim(substr($said, 0, $at));
        $category = trim(substr($said, $at + 1));
        $other = $group_model->getGroupId($named);
        return [empty($other) ? 0 : $other, $category];
    }
    /**
     * frontPageSlotNames gives the places each layout holds, by the name each
     * is saved under and the words a writer sees beside it. The form and the
     * placeholder a reader is shown for a place left empty are named from this
     * one list, so the two cannot come to say different things.
     * @param string $layout which layout to give the places of, or "" for every
     *     layout
     * @return array place name to the words describing it, under the layout
     *     name when no layout is asked for
     */
    private function frontPageSlotNames($layout = "")
    {
        $names = [
            "lead-and-list" => [
                "lead" => tl('wiki_element_front_page_lead'),
                "below" => tl('wiki_element_front_page_below')],
            "sections" => [
                "section1" => tl('wiki_element_front_page_section_one'),
                "section2" => tl('wiki_element_front_page_section_two'),
                "section3" => tl('wiki_element_front_page_section_three')],
            "cards" => [
                "card1" => tl('wiki_element_front_page_card_one'),
                "card2" => tl('wiki_element_front_page_card_two'),
                "card3" => tl('wiki_element_front_page_card_three')],
            "grid" => [
                "cell1" => tl('wiki_element_front_page_cell_one'),
                "cell2" => tl('wiki_element_front_page_cell_two'),
                "cell3" => tl('wiki_element_front_page_cell_three'),
                "cell4" => tl('wiki_element_front_page_cell_four')]
        ];
        if ($layout === "") {
            return $names;
        }
        return $names[$layout] ?? $names["grid"];
    }
    /**
     * articleCategories gives the category names a writer picked for an
     * article. They arrive as one line with commas between, the way the picker
     * keeps them, and come back with the blanks and the repeats dropped so a
     * name is written once however many times it was chosen.
     * @param object $parent controller that called this, used to clean what was
     *     sent
     * @return array the names, in the order they were picked
     */
    private function articleCategories($parent)
    {
        $line = $parent->clean($_REQUEST['article_categories'] ?? "",
            "string");
        $names = [];
        foreach (explode(",", $line) as $one) {
            $one = trim($one);
            if ($one !== "" && !in_array($one, $names)) {
                $names[] = $one;
            }
        }
        return $names;
    }
    /**
     * articleBodyWithoutCategories gives an article's words without the marks
     * at the top saying what    /** Gives an article's words without the marks
     * at the top saying what it is filed under. Those marks are how the filing
     * is kept, and a writer chooses the filing from a control of its own, so
     * seeing them in the writing area is clutter they never typed and might
     * undo by hand.
     * @param string $page the article as it is kept
     * @return string the same without its filing marks
     */
    public static function articleBodyWithoutCategories($page)
    {
        $body = $page;
        $tag = self::CATEGORY_TAG;
        while (strncasecmp(ltrim($body), $tag, strlen($tag)) === 0) {
            $body = ltrim($body);
            $close = strpos($body, "}}");
            if ($close === false) {
                break;
            }
            $body = substr($body, $close + 2);
        }
        return ltrim($body);
    }
    /**
     * articleBodyWithCategories writes the run of category tags that stands at
     * the top of an article's body, from the names a writer picked. The tags
     * are what files the page: the parser reads each one as a link to the
     * category and records it, which is how asking for a category finds the
     * page again. A tag draws as nothing, so a reader sees only the article.
     * The run an earlier save wrote is taken off first, so choosing again
     * replaces what was there instead of piling a second run on top of it. Only
     * a run at the very top is touched, since a tag a writer put in the middle
     * of the body is theirs and not this method's to move.
     * @param string $page the body as the writer left it
     * @param array $categories names the page is filed under
     * @return string the body with its tags at the top
     */
    private function articleBodyWithCategories($page, $categories)
    {
        $body = self::articleBodyWithoutCategories($page);
        $tags = "";
        foreach ($categories as $name) {
            $tags .= self::CATEGORY_TAG . $name . "}}\n";
        }
        return ($tags === "") ? $body : $tags . "\n" . $body;
    }
    /**
     * articleProperties builds the properties an article carries in its head,
     * so that a link to it posted elsewhere shows its title, its abstract and
     * its thumbnail rather than a bare address. These are the open-graph
     * properties, the set most sites read for that preview, and each is written
     * as one line naming the property and its content, which is the form the
     * head already keeps properties in. What a writer put in themselves is left
     * alone: only the lines this method writes are replaced, so a property
     * added by hand survives a save. Each category the page is filed under
     * becomes a tag property, which is the open-graph way of saying what an
     * article is about. The thumbnail is named only where the page has one, and
     * its address carries a dash where a token would go, since a reader
     * following the link from elsewhere holds no token of ours.
     * @param array $head_vars the page's settings, holding its title and its
     *     abstract
     * @param array $categories names the page is filed under
     * @param object $group_model model used to find the page and its icon
     * @param int $group_id which group the page belongs to
     * @param string $page_name the page's own name
     * @param string $locale_tag which language's copy of the page
     * @return string the properties, one to a line
     */
    private function articleProperties($head_vars, $categories,
        $group_model, $group_id, $page_name, $locale_tag)
    {
        $parent = $this->parent;
        $wiki_model = $parent->model("wiki");
        $written = ["og:type", "og:title", "og:description", "og:image",
            "og:url", "article:tag"];
        $kept = [];
        foreach (explode("\n", $head_vars['properties'] ?? "") as $line) {
            $named = trim(explode("|", $line, 2)[0]);
            if ($named !== "" && !in_array(strtolower($named), $written)) {
                $kept[] = rtrim($line, "\r");
            }
        }
        $page_id = $wiki_model->getPageId($group_id, $page_name,
            $locale_tag);
        $lines = ["og:type|article"];
        if (trim($head_vars['title'] ?? "") !== "") {
            $lines[] = "og:title|" . trim($head_vars['title']);
        }
        if (trim($head_vars['description'] ?? "") !== "") {
            $lines[] = "og:description|" .
                trim(str_replace("\n", " ", $head_vars['description']));
        }
        if (!empty($page_id) && file_exists(
            $wiki_model->getGroupPageIconPath($group_id, $page_id))) {
            $lines[] = "og:image|" . C\SHORT_BASE_URL . "wd/thumbs/-/" .
                $group_id . "/" . $page_id . "/page_icon.webp";
        }
        $lines[] = "og:url|" . C\SHORT_BASE_URL . "group/" . $group_id .
            "/" . urlencode($page_name);
        foreach ($categories as $name) {
            $lines[] = "article:tag|" . $name;
        }
        return implode("\n", array_merge($lines, $kept));
    }
    /**
     * initializeWikiPageToggle used to create Javascript used to toggle a wiki
     * page's settings control
     * @param array &$data will contain in SCRIPT field necessary Javascript to
     *     pass to view.
     */
    private function initializeWikiPageToggle(&$data)
    {
        $init_toggle_settings = (empty($data['RESOURCE_NAME'])) ?
            'setDisplay("toggle-settings", true, "inline");': '';
        $toggle_settings_on = (empty($data['RESOURCE_NAME'])) ?
            'setDisplay("toggle-settings", true);': '';
        $toggle_settings_false = (empty($data['RESOURCE_NAME'])) ?
            'setDisplay("toggle-settings", false);': '';
        $toggle_settings_inline = (empty($data['RESOURCE_NAME'])) ?
            'setDisplay("toggle-settings", true, "inline");': '';
        $data['SCRIPT'] .= <<< EOD
            mode = '{$data['MODE']}';
            function toggleSettings()
            {
                var settings = elt('p-settings');
                settings.value = (settings.value == 'true')
                    ? 'false' : 'true';
                var value = (settings.value == 'true') ? true : false;
                var r_settings =elt('r-settings');
                if (r_settings && mode == 'edit') {
                    elt('r-settings').value = settings.value;
                }
                setDisplay('page-settings', value);
                var page_type = elt("page-type");
                var cur_type = page_type.options[
                    page_type.selectedIndex].value;
                if (cur_type == "media_list" && mode == 'edit') {
                    setDisplay('save-container', value);
                }
                toggleClass("settings-toggle-button","back-gray");
            }
            ptype = document.getElementById("page-type");
            is_media_list = ('media_list'=='{$data['current_page_type']}');
            is_settings = {$data['settings']};
            is_page_alias = ('page_alias'=='{$data['current_page_type']}');
            is_url_shortener =
                ('url_shortener'=='{$data['current_page_type']}');
            is_share = ('share'=='{$data['current_page_type']}');
            setDisplay('page-settings', is_settings || is_page_alias ||
                is_share || is_url_shortener);
            setDisplay("page-container", !is_media_list && !is_page_alias &&
                !is_url_shortener && !is_share);
            setDisplay("non-alias-type", !is_page_alias && !is_url_shortener &&
                !is_share);
            setDisplay("alias-type", is_page_alias);
            setDisplay("shortener-container", is_url_shortener);
            setDisplay("short-url-label", is_url_shortener);
            setDisplay("page-resources", !is_page_alias && !is_url_shortener &&
                !is_share);
            setDisplay("share-container", is_share);
            setDisplay("page-toc-setting", !is_media_list);
            setDisplay("media-list-index-setting", is_media_list);
            function updateMediaListIndexFile()
            {
                var index_box = elt("media-list-index");
                setDisplay("media-list-index-file-setting",
                    index_box != null && index_box.checked);
            }
            var media_index_box = elt("media-list-index");
            if (media_index_box != null) {
                media_index_box.onchange = updateMediaListIndexFile;
            }
            updateMediaListIndexFile();
            if (mode == 'edit') {
                setDisplay('save-container', !is_media_list || is_settings);
                setDisplay('resource-upload-form', is_media_list &&
                    !is_share);
            }
            $init_toggle_settings
            ptype.onchange = function() {
                var cur_type = ptype.options[ptype.selectedIndex].value;
                setDisplay("page-toc-setting",
                    cur_type != "media_list");
                setDisplay("media-list-index-setting",
                    cur_type == "media_list");
                updateMediaListIndexFile();
                if (cur_type == "media_list") {
                    setDisplay("page-container", false);
                    $toggle_settings_on
                    setDisplay("non-alias-type", true);
                    setDisplay("alias-type", false);
                    setDisplay("shortener-container", false);
                    setDisplay("short-url-label", false);
                    setDisplay("share-container", false);
                    setDisplay("page-resources", true);
                    if (mode == 'edit') {
                        setDisplay("resource-upload-form", true);
                    }
                } else if (cur_type == "page_alias") {
                    $toggle_settings_false
                    setDisplay("page-container", false);
                    setDisplay("non-alias-type", false);
                    setDisplay("alias-type", true);
                    setDisplay("shortener-container", false);
                    setDisplay("short-url-label", false);
                    setDisplay("share-container", false);
                    setDisplay("page-resources", false);
                } else if (cur_type == "url_shortener") {
                     $toggle_settings_false
                     setDisplay("page-container", false);
                     setDisplay("non-alias-type", false);
                     setDisplay("alias-type", false);
                     setDisplay("shortener-container", true);
                     setDisplay("short-url-label", true);
                     setDisplay("share-container", false);
                     setDisplay("page-resources", false);
                     setDisplay("resource_msg", false);
                }  else if (cur_type == "share") {
                    $toggle_settings_false
                    setDisplay("page-container", false);
                    setDisplay("non-alias-type", false);
                    setDisplay("alias-type", false);
                    setDisplay("shortener-container", false);
                    setDisplay("short-url-label", false);
                    setDisplay("share-container", true);
                    setDisplay("page-resources", false);
                    setDisplay("resource_msg", false);
                } else {
                    setDisplay("page-container", true);
                    $toggle_settings_inline
                    setDisplay("non-alias-type", true);
                    setDisplay("alias-type", false);
                    setDisplay("shortener-container", false);
                    setDisplay("short-url-label", false);
                    setDisplay("share-container", false);
                    setDisplay("page-resources", true);
                    if (mode == 'edit') {
                        setDisplay("resource-upload-form", false);
                    }
                }
            }
EOD;
    }
}
X