<?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;
/**
 * SocialComponent provides activities to AdminController related to creating,
 * updating blogs (and blog entries), static web pages, and crawl mixes.
 * @author Chris Pollett
 */
class SocialComponent extends Component implements CrawlConstants
{
    /**
     * applyGitIssueChange carries out whichever detail-page control an editor
     * used on an issue record and hands back the changed record, or false when
     * nothing valid was asked for. A change of who holds the issue is looked up
     * by name, a fix keeps the commit that made it, and a change of urgency is
     * limited to the three known levels.
     * @param array $record the issue record to change
     * @param int $user_id id of the editor making the change
     * @param object $user_model model used to look up an assignee by name
     * @return mixed the changed record, or false when nothing changed
     */
    private function applyGitIssueChange($record, $user_id, $user_model)
    {
        $changed = false;
        $target = $_REQUEST["issue_action"] ?? "";
        $is_closed = (($record["status"] ?? "") ===
            LW\WikiIssue::STATUS_CLOSED);
        if ($target === "assigned") {
            $who = trim($_REQUEST["issue_assignee"] ?? "");
            $found = $user_model->getUser($who);
            if (!empty($found["USER_ID"])) {
                if ($is_closed) {
                    $record = LW\WikiIssue::reopen(
                        $record, $user_id, time());
                }
                $record = LW\WikiIssue::assign($record, $found["USER_ID"],
                    $user_id, time());
                $changed = true;
            }
        } else if ($target === "marked_fixed") {
            $commit = trim($_REQUEST["issue_commit"] ?? "");
            $record = LW\WikiIssue::close($record,
                LW\WikiIssue::RESOLUTION_FIXED, $user_id, time(), $commit);
            $changed = true;
        } else if ($target === "marked_wont_fix") {
            $record = LW\WikiIssue::close($record,
                LW\WikiIssue::RESOLUTION_WONT_FIX, $user_id, time());
            $changed = true;
        } else if ($target === "reported") {
            $record = LW\WikiIssue::report($record, $user_id, time());
            $changed = true;
        }
        if (isset($_REQUEST["issue_priority"])) {
            $priority = (int)$_REQUEST["issue_priority"];
            if (in_array($priority, [LW\WikiIssue::PRIORITY_LOW,
                LW\WikiIssue::PRIORITY_MEDIUM,
                    LW\WikiIssue::PRIORITY_HIGH])) {
                $record = LW\WikiIssue::setPriority($record, $priority,
                    $user_id, time());
                $changed = true;
            }
        }
        return $changed ? $record : false;
    }
    /**
     * gitIssueHistoryLines turns an issue's history into the dated lines the
     * detail page shows, keeping only the status changes and putting each into
     * words: reported, assigned to a named person, marked fixed with or without
     * a commit, marked won't fix, or reopened.
     * @param array $record the issue record whose history is wanted
     * @param object $user_model model used to name the person an issue went to
     * @return array a list of lines, each with its wording and its date
     */
    private function gitIssueHistoryLines($record, $user_model)
    {
        $parent = $this->parent;
        $lines = [];
        foreach ($record["history"] ?? [] as $entry) {
            $action = $entry["action"] ?? "";
            $when = date("Y-m-d H:i", (int)($entry["time"] ?? 0));
            if ($action === LW\WikiIssue::ACTION_OPENED) {
                $who = $parent->clean((string)
                    $user_model->getUsername($entry["by"] ?? 0), "string");
                $label = tl('social_component_git_issue_h_reported', $who);
            } else if ($action === LW\WikiIssue::ACTION_ASSIGNED) {
                $who = $parent->clean((string)
                    $user_model->getUsername($entry["to"] ?? 0), "string");
                $label = tl('social_component_git_issue_h_assigned', $who);
            } else if ($action === LW\WikiIssue::ACTION_CLOSED) {
                if (($entry["resolution"] ?? "") ===
                    LW\WikiIssue::RESOLUTION_WONT_FIX) {
                    $label = tl('social_component_git_issue_h_wont_fix');
                } else if (!empty($entry["commit"])) {
                    $commit = $parent->clean($entry["commit"], "string");
                    $label = tl('social_component_git_issue_h_fixed_at',
                        $commit);
                } else {
                    $label = tl('social_component_git_issue_h_fixed');
                }
            } else if ($action === LW\WikiIssue::ACTION_REOPENED) {
                $label = tl('social_component_git_issue_h_reopened');
            } else {
                continue;
            }
            $lines[] = ["LABEL" => $label, "WHEN" => $when];
        }
        return $lines;
    }
    /**
     * gitIssueCommentLines builds the comments shown on an issue's detail page.
     * The first comment is the issue's own description, credited to whoever
     * reported it at the time they did; the rest are the replies posted to the
     * issue's discussion thread. Every body is run through the wiki parser so
     * wiki or markdown text and uploaded resources show the way they do on a
     * normal discussion post.
     * @param array $record the issue record whose comments are wanted
     * @param array $comment_posts reply posts read from the issue's thread
     * @param int $group_id id of the group the issue belongs to
     * @param string $locale_tag language the page is written for
     * @param int $user_id id of the person viewing, for the resource token
     * @return array a list of comments, each with a name, a time, and a body
     */
    private function gitIssueCommentLines($record, $comment_posts, $group_id,
        $locale_tag, $user_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $user_model = $parent->model("user");
        $parser = new WikiParser("", true);
        $render_engine = $group_model->getRenderEngine($group_id);
        $csrf_token = C\p('CSRF_TOKEN') . "=" .
            $parent->generateCSRFToken($user_id);
        $reported_time = (int)($record["history"][0]["time"] ?? 0);
        $lines = [];
        $lines[] = [
            "NAME" => $parent->clean((string)
                $user_model->getUsername($record["reporter"] ?? 0),
                "string"),
            "WHEN" => date("Y-m-d H:i", $reported_time),
            "BODY" => $parser->parse($record["description"] ?? "",
                render_engine: $render_engine)];
        foreach ($comment_posts as $post) {
            $body = $parser->parse($post["DESCRIPTION"] ?? "",
                render_engine: $render_engine);
            $body = $wiki_model->insertResourcesParsePage($group_id,
                "post" . $post["ID"], $locale_tag, $body);
            $body = preg_replace('/\[{rtoken}\]/', $csrf_token, $body);
            $lines[] = [
                "NAME" => $parent->clean((string)($post["USER_NAME"] ?? ""),
                    "string"),
                "WHEN" => date("Y-m-d H:i", (int)($post["PUBDATE"] ?? 0)),
                "BODY" => $body];
        }
        return $lines;
    }
    /**
     * gitIssueBanEnd turns the span an editor picked into the moment a shutting
     * out ends.
     * @param string $span one of the GIT_ISSUE_BAN_ values
     * @return int when it ends as a Unix timestamp, or C\FOREVER for one that
     *     does not lapse
     */
    private function gitIssueBanEnd($span)
    {
        if ($span == C\GIT_ISSUE_BAN_FOREVER) {
            return C\FOREVER;
        }
        if ($span == C\GIT_ISSUE_BAN_MONTH) {
            return time() + C\ONE_MONTH;
        }
        return time() + C\ONE_WEEK;
    }

    /**
     * addFolderToZip puts every file in a folder, and in the folders inside it,
     * into an open archive under a folder name of its own, leaving out the
     * record of earlier versions. Kept apart from the packing itself so that a
     * page's resources and their thumbnails can each be put in under their own
     * name by the same code.
     * @param object $zip open archive to add to
     * @param string $folder path of the folder to add
     * @param string $in_zip name the folder is to have inside the archive
     */
    private function addFolderToZip($zip, $folder, $in_zip)
    {
        if (!is_dir($folder)) {
            return;
        }
        $zip->addEmptyDir($in_zip);
        $prefix_len = strlen(rtrim($folder, "/")) + 1;
        $walker = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($folder,
            \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST);
        foreach ($walker as $item) {
            $in_folder = substr($item->getPathname(), $prefix_len);
            if (in_array(self::RESOURCE_ARCHIVE_FOLDER,
                explode("/", $in_folder))) {
                continue;
            }
            if ($item->isDir()) {
                $zip->addEmptyDir("$in_zip/$in_folder");
            } else if ($item->isFile()) {
                $zip->addFile($item->getPathname(), "$in_zip/$in_folder");
            }
        }
    }
    /**
     * initializeGitIssueDetail prepares the detail page for one issue and, when
     * a group editor has just used one of its controls, makes the change first
     * so the page shows it. An editor can give the issue to someone by name,
     * mark it fixed with the commit that fixed it, mark it as one that won't be
     * acted on, reopen it, or change how urgent it is. Each change is saved
     * back onto the issue's companion page.
     * @param array &$data view data array; on return carries the detail fields
     *     the detail page renders
     * @param int $group_id id of the group the page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $prefix start of the address for links back into this page,
     *     already worked out by the caller
     * @param int $issue_number which issue to show
     */
    private function initializeGitIssueDetail(&$data, $group_id, $page_name,
        $prefix, $issue_number)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $user_model = $parent->model("user");
        $user_id = $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID;
        $locale_tag = L\getLocaleTag();
        $can_edit = !empty($data["CAN_EDIT"]);
        $can_comment = ($user_id != C\PUBLIC_USER_ID);
        $list_url = $prefix . "arg=read&repo_view=issues";
        $detail_url = $list_url . "&repo_issue=" . $issue_number;
        $data["GIT_VIEW"] = "issues";
        $data["GIT_ISSUES_URL"] = htmlentities($list_url);
        $record = $wiki_model->getGitIssue($group_id, $page_name,
            $issue_number, $locale_tag);
        if ($record === false) {
            $parent->redirectLocation($list_url);
            return;
        }
        /* Removing an issue cannot be undone, so it is asked for by a
           link carrying a token of its own, which says the ask came from
           the page rather than from somewhere else, and only someone
           allowed to edit the repository's page is offered or obeyed. */
        if ($can_edit && !empty($_REQUEST["repo_issue_delete"]) &&
            !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
            $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
            $wiki_model->deleteGroupPage($group_id, $page_name .
                C\GIT_ISSUE_SEPARATOR . $issue_number, $locale_tag);
            $parent->redirectLocation($list_url);
            return;
        }
        if ($can_edit) {
            $data["GIT_ISSUE_DELETE_URL"] = htmlentities($detail_url .
                "&repo_issue_delete=true&" . C\p('CSRF_TOKEN') . "=" .
                $parent->generateCSRFToken($user_id));
        }
        [$thread_id, $comment_posts] = $feed_model->getGitIssueThread(
            $group_id, $page_name, $issue_number, $locale_tag);
        if ($can_comment && $thread_id > 0 &&
            !empty($_REQUEST["description"]) &&
            !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
            $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
            $body = $parent->clean($_REQUEST["description"], "string");
            if (trim($body) !== "") {
                $title = "-- " . ($record["title"] ?? "");
                $post_id = $this->addGroupItemWithinLimits($thread_id,
                    $group_id,
                    $user_id, $title, $body);
                $this->handleResourceUploads($group_id, "post" . $post_id);
                $parent->redirectLocation($detail_url);
                return;
            }
        }
        if ($can_edit && !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
            $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
            $record = $this->applyGitIssueChange($record, $user_id,
                $user_model);
            if ($record !== false) {
                $wiki_model->updateGitIssue($user_id, $group_id, $page_name,
                    $issue_number, $record, $locale_tag);
                $parent->redirectLocation($detail_url);
                return;
            }
            $record = $wiki_model->getGitIssue($group_id, $page_name,
                $issue_number, $locale_tag);
        }
        $data["GIT_ISSUE_DETAIL"] = true;
        $data["GIT_ISSUE_NUMBER"] = $issue_number;
        $data["GIT_ISSUE_DISPLAY_STATUS"] =
            LW\WikiIssue::displayStatus($record);
        $data["GIT_ISSUE_IS_OPEN"] =
            (($record["status"] ?? "") === LW\WikiIssue::STATUS_OPEN);
        $data["GIT_ISSUE_PRIORITY"] = (int)($record["priority"] ?? 0);
        $data["GIT_ISSUE_TITLE"] = $parent->clean($record["title"] ?? "",
            "string");
        $data["GIT_ISSUE_HISTORY"] = $this->gitIssueHistoryLines($record,
            $user_model);
        $data["GIT_ISSUE_COMMENTS"] = $this->gitIssueCommentLines($record,
            $comment_posts, $group_id, $locale_tag, $user_id);
        $data["GIT_ISSUE_CAN_EDIT"] = $can_edit;
        $data["GIT_ISSUE_CAN_COMMENT"] = $can_comment;
        $data["GIT_ISSUE_UPLOAD_MAX"] = min(
            L\metricToInt(ini_get('upload_max_filesize')),
            L\metricToInt(ini_get('post_max_size')));
        $data["GIT_ISSUE_DETAIL_URL"] = htmlentities($detail_url);
        if ($can_edit || $can_comment) {
            $data["GIT_ISSUE_TOKEN"] = $parent->generateCSRFToken($user_id);
        }
        if ($can_comment) {
            $this->initializeWikiEditor($data, -1);
            $data['SCRIPT'] .= "if (elt('comment-add-comment')) {" .
                "initializeFileHandler('comment-add-comment', " .
                "'file-add-comment', " .
                (int)($data["GIT_ISSUE_UPLOAD_MAX"] ?? 0) .
                ", 'textarea', null, true);editorize('comment-add-comment');" .
                "setDisplay('add-comment', false);}\n";
        }
    }
    /**
     * actOnHeldGitIssue carries out an editor's decision about the reports they
     * have picked out of the queue. Accepting one gives it an issue number and
     * puts it in the list, keeping the handle its reporter gave so the issue
     * shows who reported it. Rejecting one removes it and, when a span has been
     * chosen, shuts its reporter out of reporting against this repository for
     * that long. Either way the report leaves the queue, and any number of them
     * may be decided at once.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @param string $issues_url where to send the editor afterwards
     */
    private function actOnHeldGitIssue($group_id, $page_name, $locale_tag,
        $issues_url)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $user_id = $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID;
        /* The picked rows arrive as one field naming them all, so a
           decision about any number of reports is one request. */
        $chosen = array_filter(explode(",",
            $_REQUEST["repo_issue_held"] ?? ""), "strlen");
        if (empty($chosen)) {
            $_SESSION['DISPLAY_MESSAGE'] =
                tl('social_component_pick_reports_first');
            $parent->redirectLocation($issues_url .
                "&repo_issue_filter=held");
            return;
        }
        /* Discarding and discarding with a shutting out are one control
           with a choice at its end, so the choice arrives as the action
           itself rather than as a separate field. */
        $action = $_REQUEST["repo_issue_held_action"] ?? "";
        $ban_spans = [
            "discard_week" => C\GIT_ISSUE_BAN_WEEK,
            "discard_month" => C\GIT_ISSUE_BAN_MONTH,
            "discard_forever" => C\GIT_ISSUE_BAN_FOREVER];
        $ban_span = $ban_spans[$action] ?? "";
        /* Several reports in one decision may come from one reporter, and
           shutting that reporter out is the same act however many of their
           reports are in the set, so each is shut out once. */
        $shut_out = [];
        foreach ($chosen as $one) {
            $number = (int)$parent->clean($one, "int");
            $report = $wiki_model->getHeldGitIssue($group_id, $page_name,
                $number, $locale_tag);
            if ($report === false) {
                continue;
            }
            /* The address the report came from is kept only while the
               report waits, so that rejecting it can shut its reporter
               out. It does not follow the report into the issue, where the
               token is what stands for the reporter. */
            $address = $report["reporter_address"] ?? "";
            $token = $report["reporter_token"] ?? "";
            unset($report["reporter_address"]);
            if ($action == "accept") {
                $wiki_model->createGitIssue($user_id, $group_id,
                    $page_name, $report, $locale_tag,
                    $report["title"] ?? "", $report["description"] ?? "");
            } else if ($ban_span !== "" && $token !== "" &&
                empty($shut_out[$token])) {
                $wiki_model->setGitIssueBan($user_id, $group_id,
                    $page_name, $token,
                    $this->gitIssueBanEnd($ban_span), $address,
                    $report["handle"] ?? "", $locale_tag);
                $shut_out[$token] = true;
            }
            $wiki_model->deleteHeldGitIssue($group_id, $page_name,
                $number, $locale_tag);
        }
        $parent->redirectLocation($issues_url . "&repo_issue_filter=held");
    }
    /**
     * actOnGitIssueBan carries out an editor's decision about a reporter who
     * has been shut out: letting them back in, or leaving them shut out for a
     * different length of time than they were given.
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @param string $issues_url where to send the editor afterwards
     */
    private function actOnGitIssueBan($group_id, $page_name, $locale_tag,
        $issues_url)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $user_id = $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID;
        $token = $parent->clean($_REQUEST["repo_ban_token"] ?? "", "string");
        $action = $_REQUEST["repo_ban_action"] ?? "";
        $search = $parent->clean($_REQUEST["repo_ban_search"] ?? "",
            "string");
        $back = $issues_url . "&repo_issue_filter=banned" .
            "&repo_ban_search=" . urlencode($search);
        if ($token === "") {
            $parent->redirectLocation($back);
            return;
        }
        if ($action == "unban") {
            $wiki_model->deleteGitIssueBan($group_id, $page_name, $token,
                $locale_tag);
            $parent->redirectLocation($back);
            return;
        }
        $spans = ["ban_week" => C\GIT_ISSUE_BAN_WEEK,
            "ban_month" => C\GIT_ISSUE_BAN_MONTH,
            "ban_forever" => C\GIT_ISSUE_BAN_FOREVER];
        if (!isset($spans[$action])) {
            $parent->redirectLocation($back);
            return;
        }
        $record = $wiki_model->getGitIssueBan($group_id, $page_name,
            $token, $locale_tag);
        $wiki_model->setGitIssueBan($user_id, $group_id, $page_name,
            $token, $this->gitIssueBanEnd($spans[$action]),
            ($record["address"] ?? ""), ($record["handle"] ?? ""),
            $locale_tag);
        $parent->redirectLocation($back);
    }
    /**
     * gitListRowsHtml builds the table rows for a page of the commit list or
     * the tag list, already escaped. A commit row shows the date, author, and
     * message; a tag row shows the tag name, date, and message. Both end with
     * links to browse that snapshot's files and to download it as a tar.gz or a
     * zip.
     * @param array $rows the commits or tags to render
     * @param string $kind either "commits" or "tags"
     * @param string $prefix start of every read-view link
     * @param string $branch name of the branch currently chosen
     * @return string the rows as HTML
     */
    private function gitListRowsHtml($rows, $kind, $prefix, $branch)
    {
        $parent = $this->parent;
        $branch_query = "arg=read&repo_branch=" . rawurlencode($branch);
        $html = "";
        foreach ($rows as $row) {
            if ($kind === "tags") {
                $ref = "&repo_ref=" . rawurlencode($row["name"]);
            } else {
                $ref = "&repo_commit=" . $row["sha"];
            }
            $browse = htmlentities($prefix . $branch_query . $ref .
                "&repo_path=");
            $diff = htmlentities($prefix . $branch_query . "&repo_commit=" .
                $row["sha"] . "&repo_view=diff");
            $targz = htmlentities($prefix . $branch_query . $ref .
                "&repo_download=targz");
            $zip = htmlentities($prefix . $branch_query . $ref .
                "&repo_download=zip");
            $date = date("Y-m-d H:i", $row["time"]);
            $subject = $parent->clean($row["subject"], "string");
            $actions = "<a class='git-action' title='" .
                tl("wiki_element_git_browse") . "' href='" . $browse .
                "'>&#128194;</a><a class='git-action' title='" .
                tl("wiki_element_git_diff") . "' href='" . $diff .
                "'>&lt;/&gt;</a><a class='git-action' title='tar.gz'" .
                " href='" . $targz .
                "'>&#128476;&#65039;</a><a class='git-action' " .
                "title='zip' href='" . $zip . "'>&#128230;</a>";
            if ($kind === "tags") {
                $html .= "<tr><td class='git-name-col'>" .
                    $parent->clean($row["name"], "string") .
                    "</td><td class='git-date-col'>" . $date .
                    "</td><td class='git-msg-col'>" . $subject .
                    "</td><td class='git-actions-col'>" . $actions .
                    "</td></tr>";
            } else {
                $html .= "<tr><td class='git-date-col'>" . $date .
                    "</td><td class='git-author-col'>" .
                    $parent->clean($row["author"], "string") .
                    "</td><td class='git-msg-col'>" . $subject .
                    "</td><td class='git-actions-col'>" . $actions .
                    "</td></tr>";
            }
        }
        return $html;
    }
    /**
     * gitSendDownloadFile streams a finished download file back to the reader
     * as an attachment and removes it. Shared by the whole-repository archive,
     * the single folder archive, so the download headers and cleanup are
     * written in one place.
     * @param string $temp_path file holding the built download
     * @param string $content_type MIME type to label the download with
     * @param string $download_name file name the browser saves it under
     */
    private function gitSendDownloadFile($temp_path, $content_type,
        $download_name)
    {
        $parent = $this->parent;
        $parent->web_site->header("HTTP/1.1 200 OK");
        $parent->web_site->header("Content-Type: " . $content_type);
        $parent->web_site->header("Content-Disposition: attachment; " .
            "filename=\"" . $download_name . "\"");
        $parent->web_site->header("Content-Length: " .
            filesize($temp_path));
        readfile($temp_path);
        @unlink($temp_path);
        \seekquarry\atto\webExit();
    }
    /**
     * gitFilterSortRows filters and sorts the commits or tags for the read
     * view's search box and sortable column headers. When a search term is
     * given, only rows whose text (a commit's message, author, and date, or a
     * tag's name and date) contains it are kept. When a sort column is given,
     * the rows are ordered by that column's value in the chosen direction.
     * Called with the whole bounded history so the result stays consistent as
     * the reader scrolls.
     * @param array $rows the commits or tags to narrow and order
     * @param string $kind either "commits" or "tags"
     * @param string $search text to keep only matching rows, or "" for all
     * @param string $sort column to order by: "date", "author", "message", or
     *     "tag", or "" to leave the order unchanged
     * @param string $direction "asc" or "desc"
     * @return array the kept rows in the chosen order
     */
    private function gitFilterSortRows($rows, $kind, $search, $sort,
        $direction)
    {
        if ($search !== "") {
            $needle = mb_strtolower($search);
            $rows = array_values(array_filter($rows,
                function ($row) use ($needle, $kind) {
                    if ($kind === "tags") {
                        $text = $row["name"] . " " .
                            date("Y-m-d", $row["time"]);
                    } else {
                        $text = $row["subject"] . " " . $row["author"] .
                            " " . date("Y-m-d", $row["time"]);
                    }
                    return str_contains(mb_strtolower($text), $needle);
                }));
        }
        $keys = ["date" => "time", "author" => "author",
            "message" => "subject", "tag" => "name"];
        if (isset($keys[$sort])) {
            $key = $keys[$sort];
            usort($rows, function ($first, $second) use ($key) {
                if ($key === "time") {
                    return $first["time"] <=> $second["time"];
                }
                return strcasecmp((string)($first[$key] ?? ""),
                    (string)($second[$key] ?? ""));
            });
            if ($direction === "desc") {
                $rows = array_reverse($rows);
            }
        }
        return $rows;
    }

    /**
     * folderContentsLen adds up how much room every file in a folder takes,
     * including those in folders inside it, leaving out the folder of earlier
     * versions. A page's resources sit in a folder with folders of their own
     * beside them, so the size of the folder itself says nothing; this walks
     * the lot. It is used to decide whether a page's resources are few enough
     * to be offered as one download, which has to be known before any of the
     * archive is built rather than discovered part way through it.
     * @param string $folder path of the folder to measure
     * @return int total size in bytes of every file at or below it that would
     *     go into a download
     */
    private function folderContentsLen($folder)
    {
        $total = 0;
        if (!is_dir($folder)) {
            return $total;
        }
        $walker = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($folder,
            \FilesystemIterator::SKIP_DOTS));
        $prefix_len = strlen(rtrim($folder, "/")) + 1;
        foreach ($walker as $item) {
            if ($item->isFile() && !in_array(self::RESOURCE_ARCHIVE_FOLDER,
                explode("/", substr($item->getPathname(), $prefix_len)))) {
                $total += $item->getSize();
            }
        }
        return $total;
    }
    /**
     * streamResourcesZip sends a page's resources and their thumbnails as one
     * zip download. Someone who wants a copy of what a page holds would
     * otherwise have to fetch the files one at a time, and the folders beside
     * them a level at a time. The archive opens as a single folder holding a
     * resources and a thumbs folder, so it unpacks as the page keeps them
     * rather than as a heap; it is built on disk and read back a block at a
     * time rather than held whole, so a large page does not cost the always-on
     * server its own size in memory, and the file is removed once it has gone
     * out.
     * @param string $folder path of the page's resource folder
     * @param string $thumb_folder path of the folder holding the thumbnails of
     *     those resources
     * @param string $page_name name of the wiki page whose resources these are,
     *     which names both the file and the folder it opens as
     * @return bool whether the archive could be made and sent; false when there
     *     was nowhere to build it or it would not open, so the caller can say
     *     so rather than send an empty download
     */
    private function streamResourcesZip($folder, $thumb_folder,
        $page_name)
    {
        $parent = $this->parent;
        $tmp_path = tempnam(sys_get_temp_dir(), 'yioop_resources_');
        if ($tmp_path === false) {
            return false;
        }
        $zip = new \ZipArchive();
        if ($zip->open($tmp_path, \ZipArchive::CREATE |
            \ZipArchive::OVERWRITE) !== true) {
            unlink($tmp_path);
            return false;
        }
        $named = urlencode(str_replace(" ", "_", trim($page_name)));
        $top = ($named === "") ? self::PAGE_RESOURCES_ZIP_FOLDER : $named;
        $this->addFolderToZip($zip, $folder, "$top/resources");
        $this->addFolderToZip($zip, $thumb_folder, "$top/thumbs");
        $zip->close();
        $size = filesize($tmp_path);
        $parent->web_site->header('Content-Type: application/zip');
        $parent->web_site->header('Content-Length: ' . $size);
        $parent->web_site->header(
            'Content-Disposition: attachment; filename="' . $top .
            '.zip"');
        $parent->web_site->header('X-Content-Type-Options: nosniff');
        $parent->web_site->stream(function () use ($tmp_path) {
            $handle = fopen($tmp_path, "rb");
            try {
                while (!feof($handle)) {
                    $bytes = fread($handle,
                        C\RESOURCE_STREAM_BLOCK_LEN);
                    if ($bytes === false || $bytes === "") {
                        break;
                    }
                    yield $bytes;
                }
            } finally {
                fclose($handle);
                unlink($tmp_path);
            }
        });
        return true;
    }
    /**
     * requestResourceNames reads the list of resource names a request carries
     * for an action that acts on several at once. The listing page sends what a
     * person picked out as one field holding a JSON array of names, so removing
     * or moving a dozen resources is one request rather than a dozen. Each name
     * is cleaned the same way a single-resource request's name is, and anything
     * that is not a usable name is dropped.
     * @param object $parent controller calling this, used to clean values
     * @param string $field name of the request field holding the list
     * @return array the resource names, cleaned, possibly empty
     */
    private function requestResourceNames($parent, $field)
    {
        if (empty($_REQUEST[$field])) {
            return [];
        }
        $names = json_decode($_REQUEST[$field], true);
        if (!is_array($names)) {
            return [];
        }
        $clean_names = [];
        foreach ($names as $name) {
            if (!is_string($name)) {
                continue;
            }
            $clean_name = $parent->clean($name, "file_name");
            if ($clean_name != "") {
                $clean_names[] = $clean_name;
            }
        }
        return $clean_names;
    }
    /**
     * reportResourcesMoved records that resources were moved and sends the
     * person back to the folder they were arranging with a message saying how
     * it went.
     * @param object $parent controller calling this, used to redirect
     * @param object $group_model used to record a new version of the page
     * @param int $moved_count how many resources were actually moved
     * @param int $user_id who is doing the moving
     * @param array $page_info the wiki page the resources belong to
     * @param array $preserve_fields request fields the redirect keeps
     * @return mixed whatever the redirect returns
     */
    private function reportResourcesMoved($parent, $group_model,
        $moved_count, $user_id, $page_info, $preserve_fields)
    {
        $wiki_model = $parent->model("wiki");
        if ($moved_count > 0) {
            $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                tl('social_component_resource_moved'));
            return $parent->redirectWithMessage(
                tl('social_component_resource_moved'), $preserve_fields);
        }
        return $parent->redirectWithMessage(
            tl('social_component_resource_not_moved'), $preserve_fields);
    }
    /**
     * videoFiguresFromLibrary reads a video's frame width, frame height
     * and running time using Yioop's own video library, without starting
     * an outside program. The library opens the file, reads its headers,
     * and reports the figures the page needs to size the player it
     * draws. videoProbeInfo calls it when no saved note holds the
     * figures; it sits on this component, the base the resource
     * component extends, so both reach it.
     *
     * @param string $media_path path to the video file
     * @return array width, height and duration in seconds, or an empty
     *     array when the file is not a video the library can read
     */
    public static function videoFiguresFromLibrary($media_path)
    {
        try {
            $reader = VideoExtractor::open($media_path);
            $seconds = $reader->durationSeconds();
            $wide = $reader->frameWidth();
            $high = $reader->frameHeight();
        } catch (\Exception $trouble) {
            return [];
        }
        if ($wide < 1 || $high < 1 || $seconds <= 0) {
            return [];
        }
        return ['width' => $wide, 'height' => $high,
            'duration' => $seconds];
    }
    /**
     * videoProbeInfo gives a video's frame width, frame height and
     * running time, keeping a note of them beside its thumbnail so the
     * figures are read from the file only once. These figures go into a
     * video's page as the player's size and length; reading them on every
     * view of the page would open the file each time.
     *
     * @param string $media_path path to the video file
     * @param string $thumb_folder folder the thumbnails sit in, where the
     *     note is kept; empty when there is no such folder, in which case
     *     the file is read each time
     * @param string $media_name name of the video within its folder
     * @return array width, height and duration of the video, or an empty
     *     array when the figures could not be read
     */
    private function videoProbeInfo($media_path, $thumb_folder, $media_name)
    {
        $modified = filemtime($media_path);
        $note_path = "";
        if (!empty($thumb_folder) && is_dir($thumb_folder)) {
            $note_path = $thumb_folder . "/" . L\crawlHash($media_name) .
                ".probe.txt";
        }
        if ($note_path != "" && file_exists($note_path)) {
            $note = json_decode(file_get_contents($note_path), true);
            if (!empty($note['modified']) && $note['modified'] == $modified &&
                isset($note['width'], $note['height'], $note['duration'])) {
                return ['width' => $note['width'],
                    'height' => $note['height'],
                    'duration' => $note['duration']];
            }
        }
        $probe = self::videoFiguresFromLibrary($media_path);
        if (empty($probe)) {
            return [];
        }
        if ($note_path != "") {
            file_put_contents($note_path, json_encode(
                array_merge(['modified' => $modified], $probe)));
        }
        return $probe;
    }
    /**
     * gitStatsBars turns a set of statistic labels and their counts into rows
     * the view can draw as a simple bar chart. Each row carries the escaped
     * label, the count, and the width its bar should take as a percentage of
     * the largest count, so the view has no arithmetic to do. Labels come from
     * repository contents, so they are escaped here before reaching the view.
     * @param object $parent controller used to escape the labels
     * @param array $counts label to count, in the order they should appear
     * @return array one row per label, each ["label" => escaped label, "count"
     *     => the count, "width" => bar width as a whole-number percentage of
     *     the largest count]
     */
    private function gitStatsBars($parent, $counts)
    {
        $largest = 0;
        foreach ($counts as $count) {
            if ($count > $largest) {
                $largest = $count;
            }
        }
        $full = 100;
        $bars = [];
        foreach ($counts as $label => $count) {
            $width = ($largest > 0) ?
                (int)round($full * $count / $largest) : 0;
            $bars[] = ["label" => $parent->clean((string)$label, "string"),
                "count" => $count, "width" => $width];
        }
        return $bars;
    }
    /**
     * initializeGitIssues prepares the issue view of a Git repository wiki page
     * and, when a signed-in visitor has just filled in the new-issue form,
     * reports the issue first so it appears in the list. The list can be
     * narrowed to open, closed, unassigned, or the visitor's own issues.
     * Reporting an issue stores its record, including the branch and version it
     * was seen on, on a hidden companion page and starts that issue's own
     * discussion thread. The reporter does not pick an urgency; a new issue
     * starts at middle urgency and an editor changes it later.
     * @param array &$data view data array; on return carries the GIT_ISSUE
     *     fields the issue view renders
     * @param int $group_id id of the group the page belongs to
     * @param string $page_name name of the Git repository wiki page
     * @param string $prefix start of the address for links back into this page,
     *     already worked out by the caller
     * @param object $repository the open repository, used to offer recent
     *     release tags as version choices
     * @param string $branch name of the branch being shown, the default choice
     *     in the new-issue form
     * @param array $branches map from each branch name to the commit it points
     *     at, used to check the chosen branch and to stand in for the current-
     *     code version
     */
    private function initializeGitIssues(&$data, $group_id, $page_name,
        $prefix, $repository, $branch, $branches)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $user_id = $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID;
        $locale_tag = L\getLocaleTag();
        $issue_number = isset($_REQUEST["repo_issue"]) ?
            (int)$_REQUEST["repo_issue"] : 0;
        if ($issue_number > 0) {
            $this->initializeGitIssueDetail($data, $group_id, $page_name,
                $prefix, $issue_number);
            return;
        }
        $issues_url = $prefix . "arg=read&repo_view=issues";
        $data["GIT_VIEW"] = "issues";
        $data["GIT_ISSUES_URL"] = htmlentities($issues_url);
        $data["GIT_ISSUE_FILTER_URL"] = htmlentities($prefix .
            "arg=read&repo_view=issues&repo_issue_filter=");
        /* Whether reports from people with no account are taken, and
           whether they wait for an editor first, is a setting on the
           repository page itself, edited beside its other page settings. */
        $anon_setting = $data["HEAD"]['anonymous_issue_reporting'] ??
            C\GIT_ISSUE_ANON_MODERATED;
        $is_anonymous = ($user_id == C\PUBLIC_USER_ID);
        $can_edit = !empty($data["CAN_EDIT"]);
        $can_report = !$is_anonymous ||
            ($anon_setting != C\GIT_ISSUE_ANON_NONE);
        $data["GIT_ISSUE_CAN_REPORT"] = $can_report;
        $data["GIT_ISSUE_ANONYMOUS"] = $is_anonymous;
        list(, $held_total) = $wiki_model->heldGitIssuePage(
            $group_id, $page_name, $locale_tag, 0, 0);
        $data["GIT_ISSUE_HELD_COUNT"] = $held_total;
        if ($can_report) {
            $data["GIT_ISSUE_TOKEN"] = $parent->generateCSRFToken($user_id);
        }
        $branch_names = [];
        foreach (array_keys($branches) as $branch_name) {
            $branch_names[] = $parent->clean($branch_name, "string");
        }
        $data["GIT_ISSUE_BRANCHES"] = $branch_names;
        $data["GIT_ISSUE_DEFAULT_BRANCH"] = $parent->clean($branch,
            "string");
        $version_tags = [];
        foreach (array_slice($repository->tags(), 0,
            C\GIT_ISSUE_VERSION_CHOICES) as $tag) {
            $version_tags[] = $parent->clean($tag["name"], "string");
        }
        $data["GIT_ISSUE_VERSIONS"] = $version_tags;
        if ($can_edit && !empty($_REQUEST["repo_ban_action"]) &&
            !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
            $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
            $this->actOnGitIssueBan($group_id, $page_name, $locale_tag,
                $issues_url);
            return;
        }
        if ($can_edit && !empty($_REQUEST["repo_issue_held_action"]) &&
            !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
            $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
            $this->actOnHeldGitIssue($group_id, $page_name,
                $locale_tag, $issues_url);
            return;
        }
        if ($can_report && !empty($_REQUEST["issue_title"]) &&
            !empty($_REQUEST[C\p('CSRF_TOKEN')]) &&
            $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id)) {
            $take_report = !$is_anonymous ||
                $this->anonymousReportPasses($data, $group_id,
                    $page_name, $locale_tag);
            if ($take_report) {
                $title = trim($_REQUEST["issue_title"]);
                $description = trim($_REQUEST["issue_description"] ?? "");
                $chosen_branch = $_REQUEST["issue_branch"] ?? $branch;
                if (!isset($branches[$chosen_branch])) {
                    $chosen_branch = $branch;
                }
                $chosen_version = $_REQUEST["issue_version"] ?? "current";
                $tag_shas = [];
                foreach ($repository->tags() as $tag) {
                    $tag_shas[$tag["name"]] = $tag["sha"];
                }
                if (isset($tag_shas[$chosen_version])) {
                    $version = $chosen_version;
                } else {
                    $version = $branches[$chosen_branch] ?? "";
                }
                $record = LW\WikiIssue::open($user_id, time(), $title,
                    $chosen_branch, $version);
                $record["description"] = $description;
                if ($is_anonymous) {
                    $record["handle"] = substr(trim(
                        $parent->clean($_REQUEST["issue_handle"] ?? "",
                        "string")), 0, C\GIT_ISSUE_HANDLE_LEN);
                    $record["reporter_token"] =
                        (substr(L\crawlHash(L\remoteAddress() .
                            C\p('AUTH_KEY')), 0,
                                C\GIT_ISSUE_REPORTER_TOKEN_LEN));
                    $record["reporter_address"] = L\remoteAddress();
                }
                /* A report from someone with no account waits for an
                   editor unless this repository has been set to take them
                   straight, so a run of spam fills a queue rather than
                   spending a block of issue numbers that stay spent once
                   the spam is gone. Either way the reporter is told what
                   was just sent, since they have no account to find it
                   under afterwards. */
                $said_back = "";
                if ($is_anonymous &&
                    $anon_setting == C\GIT_ISSUE_ANON_MODERATED) {
                    $wiki_model->addHeldGitIssue($user_id, $group_id,
                        $page_name, $record, $locale_tag);
                    $data["GIT_ISSUE_HELD_COUNT"] = $held_total + 1;
                    $said_back = tl('social_component_issue_waiting') .
                        " " . $parent->clean($title, "string");
                } else {
                    $number = $wiki_model->createGitIssue($user_id,
                        $group_id, $page_name, $record, $locale_tag,
                        $title, $description);
                    $said_back = tl('social_component_issue_added') . " " .
                        $number . ": " . $parent->clean($title, "string");
                }
                /* Someone signed in is sent back to a clean address, where
                   what they reported is in the list under their name.
                   Someone signed out is told on this page instead: they
                   have no account to find it under, and if they have not
                   accepted cookies there is nothing that would carry a
                   message across a redirect. */
                if (!$is_anonymous) {
                    $parent->redirectLocation($issues_url);
                    return;
                }
                $data['DISPLAY_MESSAGE'] = $said_back;
            }
        }
        /* A signed-out reporter has no account standing behind the
           submission, so the form is given the same work-and-decoy checks
           the wiki forms use: a number the browser has to grind out, a
           decoy field only a form-filler completes, and a session
           timestamp the arrival is timed against. Setting this up hands
           out a fresh string and time, which is why it happens here,
           after any submission has been judged: doing it earlier would
           replace the string a submission was answering before the answer
           was looked at, and no answer could ever be right. */
        if ($can_report && $is_anonymous) {
            $parent->setupProofOfWorkViewData($data);
            $data["GIT_ISSUE_HONEYPOT"] = C\WIKI_FORM_HONEYPOT_FIELD;
            $proof_time = time();
            $data["GIT_ISSUE_PROOF_TIME"] = $proof_time;
            $data["GIT_ISSUE_PROOF_STRING"] =
                md5($proof_time . C\p('AUTH_KEY'));
            $data["GIT_ISSUE_PROOF_LEVEL"] = $parent::HASH_CAPTCHA_LEVEL;
        }
        $filter = $_REQUEST["repo_issue_filter"] ?? "open";
        $filter_names = ["reported", "assigned", "marked_fixed",
            "marked_wont_fix", "open", "closed", "mine", "all"];
        if ($can_edit) {
            $filter_names[] = "held";
            $filter_names[] = "banned";
        }
        if (!in_array($filter, $filter_names)) {
            $filter = "open";
        }
        $data["GIT_ISSUE_CAN_MODERATE"] = $can_edit;
        $data["GIT_ISSUE_FILTER"] = $filter;
        /* A list is read by scrolling it, and reaching its end asks for
           the next page on its own. Such a request is answered with the
           rows alone, which the browser adds to the table it already has,
           so the rest of the page is neither built nor sent again. */
        $data["GIT_ISSUE_ROWS_ONLY"] = !empty($_REQUEST["f"]) &&
            $_REQUEST["f"] == "api";
        $per_page = C\NUM_RESULTS_PER_PAGE;
        $data["RESULTS_PER_PAGE"] = $per_page;
        $start = max(0, (int)($_REQUEST["repo_offset"] ?? 0));
        $data["LIMIT"] = $start;
        /* This address is read by a script rather than placed in markup,
           so its separators are left as they are: written as entities they
           would reach the server as part of the field names. */
        $data["GIT_ISSUE_PAGE_URL"] = $issues_url .
            "&repo_issue_filter=" . $filter . "&repo_offset=";
        $user_model = $parent->model("user");
        if ($filter == "banned") {
            $held_url = $issues_url . "&repo_issue_filter=held";
            $data["GIT_ISSUE_HELD_URL"] = htmlentities($held_url);
            $search = $parent->clean($_REQUEST["repo_ban_search"] ?? "",
                "string");
            $data["GIT_ISSUE_BAN_SEARCH"] = $search;
            $data["GIT_ISSUE_BAN_BASE"] = htmlentities($issues_url .
                "&repo_issue_filter=banned");
            list($bans, $total) = $wiki_model->gitIssueBanPage($group_id,
                $page_name, $locale_tag, $search, $start, $per_page);
            $ban_rows = [];
            foreach ($bans as $ban) {
                $ban_rows[] = [
                    "TOKEN" => $ban["token"],
                    "HANDLE" => $parent->clean($ban["handle"] ?? "",
                        "string"),
                    "UNTIL" => $ban["until"]];
            }
            $data["GIT_ISSUE_BAN_ROWS"] = $ban_rows;
            $data["TOTAL_ROWS"] = $total;
            $data["GIT_ISSUE_PAGE_URL"] = $issues_url .
                "&repo_issue_filter=banned&repo_ban_search=" .
                urlencode($search) . "&repo_offset=";
            return;
        }
        if ($filter == "held") {
            $data["GIT_ISSUE_BAN_URL"] = htmlentities($issues_url .
                "&repo_issue_filter=banned");
            $held_url = $issues_url . "&repo_issue_filter=held";
            $data["GIT_ISSUE_HELD_URL"] = htmlentities($held_url);
            $looking_at = (int)($_REQUEST["repo_issue_held_view"] ?? 0);
            if ($looking_at > 0) {
                $report = $wiki_model->getHeldGitIssue($group_id,
                    $page_name, $looking_at, $locale_tag);
                if ($report !== false) {
                    $data["GIT_ISSUE_HELD_DETAIL"] = $report;
                    $data["GIT_ISSUE_HELD_POSITION"] = $looking_at;
                }
            }
            list($reports, $total) = $wiki_model->heldGitIssuePage(
                $group_id, $page_name, $locale_tag, $start, $per_page);
            $held_rows = [];
            foreach ($reports as $number => $report) {
                $held_rows[] = [
                    "NUMBER" => $number,
                    "handle" => $report["handle"] ?? "",
                    "reporter_token" => $report["reporter_token"] ?? "",
                    "title" => $parent->clean($report["title"] ?? "",
                        "string"),
                    "REPORTED" => LW\WikiIssue::reportedTime($report),
                    "URL" => htmlentities($held_url .
                        "&repo_issue_held_view=" . $number)];
            }
            $data["GIT_ISSUE_HELD_ROWS"] = $held_rows;
            $data["TOTAL_ROWS"] = $total;
            return;
        }
        list($issues, $total) = $wiki_model->gitIssuePage($group_id,
            $page_name, $locale_tag, $filter, $user_id, $start, $per_page);
        $data["TOTAL_ROWS"] = $total;
        $rows = [];
        foreach ($issues as $number => $record) {
            $reporter = (int)($record["reporter"] ?? 0);
            $assignee = (int)($record["assignee"] ?? 0);
            $reporter_name = LW\WikiIssue::reporterName($record,
                $reporter > 0 ? $user_model->getUsername($reporter) : "");
            $assignee_name = $assignee > 0 ?
                (string)$user_model->getUsername($assignee) : "";
            $status_user = LW\WikiIssue::statusUser($record);
            $status_user_name = $status_user > 0 ?
                (string)$user_model->getUsername($status_user) : "";
            $rows[] = ["NUMBER" => $number,
                "TITLE" => $parent->clean($record["title"] ?? "", "string"),
                "STATUS" => LW\WikiIssue::displayStatus($record),
                "PRIORITY" => $record["priority"] ?? 0,
                "REPORTER" => $parent->clean($reporter_name, "string"),
                "ASSIGNEE" => $parent->clean($assignee_name, "string"),
                "STATUS_USER" =>
                    $parent->clean($status_user_name, "string"),
                "UPDATED_TIME" => (int)($record["last_modified"] ?? 0),
                "URL" => htmlentities($issues_url . "&repo_issue=" .
                    $number)];
        }
        $data["GIT_ISSUE_ROWS"] = $rows;
    }
    /**
     * gitRenderableMediaType gives the media type a repository file should be
     * drawn inline as, based on its name ending, for the file types a browser
     * can show directly: common images, PDF, and common audio and video files.
     * Returns the empty string for any other file, which is shown as text or
     * noted as binary instead.
     * @param string $name the file's name
     * @return string the media type, such as "image/png", "video/mp4", or
     *     "application/pdf", or "" when the file is not one a browser draws
     */
    private function gitRenderableMediaType($name)
    {
        $media_types = ["png" => "image/png", "gif" => "image/gif",
            "jpg" => "image/jpeg", "jpeg" => "image/jpeg",
            "webp" => "image/webp", "bmp" => "image/bmp",
            "ico" => "image/x-icon", "svg" => "image/svg+xml",
            "pdf" => "application/pdf",
            "mp3" => "audio/mpeg", "wav" => "audio/wav",
            "ogg" => "audio/ogg", "oga" => "audio/ogg",
            "m4a" => "audio/mp4", "aac" => "audio/aac",
            "flac" => "audio/flac",
            "mp4" => "video/mp4", "m4v" => "video/mp4",
            "webm" => "video/webm", "ogv" => "video/ogg",
            "mov" => "video/quicktime"];
        $ending = strtolower(pathinfo($name, PATHINFO_EXTENSION));
        return $media_types[$ending] ?? "";
    }
    /**
     * gitRefMenu builds the ref chooser's menu: the list a reader opens beside
     * the branch chooser to move between points in the repository's history. It
     * offers the branch's latest commit (its HEAD), a way into the full commit
     * list, the few most recent commits directly, and, when the repository has
     * tags, a way into the full tag list and the few most recent tags directly.
     * Picking a commit or tag shows that snapshot's files.
     * @param object $repository the repository being read
     * @param string $prefix start of every read-view link, carrying the page
     *     address and any request token
     * @param string $branch name of the branch currently chosen
     * @param string $view_commit object name of the commit whose recent history
     *     the menu lists
     * @return array menu rows, each ["LABEL" => text shown, "URL" => where it
     *     leads, "KIND" => "head" for a section opener or "item" for an
     *     ordinary entry]
     */
    private function gitRefMenu($repository, $prefix, $branch, $view_commit)
    {
        $parent = $this->parent;
        $branch_query = "arg=read&repo_branch=" . rawurlencode($branch);
        $menu = [["LABEL" => tl("wiki_element_git_head"),
            "URL" => htmlentities($prefix . $branch_query . "&repo_path="),
            "KIND" => "item"]];
        $menu[] = ["LABEL" => tl("wiki_element_git_commit_list"),
            "URL" => htmlentities($prefix . $branch_query .
            "&repo_view=commits"), "KIND" => "head"];
        foreach ($repository->commitList($view_commit, 0,
            C\GIT_RECENT_REF_COUNT) as $entry) {
            $menu[] = ["LABEL" => substr($entry["sha"], 0,
                C\GIT_SHORT_HASH_LENGTH) . "  " .
                $parent->clean($entry["subject"], "string"),
                "URL" => htmlentities($prefix . $branch_query .
                "&repo_commit=" . $entry["sha"] . "&repo_path="),
                "KIND" => "item"];
        }
        $tags = $repository->tags();
        if (!empty($tags)) {
            $menu[] = ["LABEL" => tl("wiki_element_git_tag_list"),
                "URL" => htmlentities($prefix . $branch_query .
                "&repo_view=tags"), "KIND" => "head"];
            foreach (array_slice($tags, 0, C\GIT_RECENT_REF_COUNT)
                as $tag) {
                $menu[] = ["LABEL" =>
                    $parent->clean($tag["name"], "string"),
                    "URL" => htmlentities($prefix . $branch_query .
                    "&repo_ref=" . rawurlencode($tag["name"]) .
                    "&repo_path="), "KIND" => "item"];
            }
        }
        return $menu;
    }
    /**
     * gitListView prepares one page of the commit list or the tag list for the
     * read view. The rows are built already escaped and ready to place in the
     * table. When the request is the background one the page makes as the
     * reader scrolls, just the rows are sent back so they can be added to the
     * end of the table; otherwise the rows and the address to fetch the next
     * page from are handed to the view to draw the first page.
     * @param array &$data view data to add the list to
     * @param object $repository the repository being read
     * @param string $view_commit object name of the commit the commit list
     *     starts from
     * @param string $prefix start of every read-view link
     * @param string $branch name of the branch currently chosen
     * @param string $ref_query the part of a link naming the chosen commit or
     *     tag, if any
     * @param string $kind either "commits" or "tags"
     */
    private function gitListView(&$data, $repository, $view_commit, $prefix,
        $branch, $ref_query, $kind)
    {
        $parent = $this->parent;
        /*
           The list request's own fields are read here in one place. The
           offset is a count, the sort column and direction are checked
           below against a fixed set of choices, and the search text is
           only ever compared against and shown back escaped, so, as in
           the read view above, these travel from here as the raw text the
           visitor sent.
        */
        $offset = (isset($_REQUEST["repo_offset"]) &&
            ctype_digit((string)$_REQUEST["repo_offset"])) ?
            (int)$_REQUEST["repo_offset"] : 0;
        $sort = $_REQUEST["repo_sort"] ?? "";
        $direction = (($_REQUEST["repo_dir"] ?? "") === "asc") ?
            "asc" : "desc";
        $search = trim($_REQUEST["repo_search"] ?? "");
        $send_rows_only = !empty($_REQUEST["repo_rows"]);
        $page_size = C\GIT_LIST_PAGE_SIZE;
        if ($kind === "tags") {
            $rows = array_slice($repository->tags(), $offset, $page_size);
        } else {
            $rows = $repository->commitList($view_commit, $offset,
                $page_size);
        }
        if ($sort !== "" || $search !== "") {
            if ($kind === "tags") {
                $full = $repository->tags();
            } else {
                $full = $repository->commitList($view_commit, 0, 0);
            }
            $full = $this->gitFilterSortRows($full, $kind, $search, $sort,
                $direction);
            $rows = array_slice($full, $offset, $page_size);
        }
        $rows_html = $this->gitListRowsHtml($rows, $kind, $prefix, $branch);
        if ($send_rows_only) {
            $parent->web_site->header(
                "Content-Type: text/html; charset=utf-8");
            echo $rows_html;
            \seekquarry\atto\webExit();
            return;
        }
        $sort_query = "";
        if ($sort !== "") {
            $sort_query = "&repo_sort=" . rawurlencode($sort) .
                "&repo_dir=" . $direction;
        }
        if ($search !== "") {
            $sort_query .= "&repo_search=" . rawurlencode($search);
        }
        $data["GIT_VIEW"] = $kind;
        $data["GIT_LIST_ROWS"] = $rows_html;
        $data["GIT_LIST_SORT"] = $sort;
        $data["GIT_LIST_DIR"] = $direction;
        $data["GIT_LIST_SEARCH"] = $parent->clean($search, "string");
        $data["GIT_LIST_SORT_BASE"] = htmlentities($prefix .
            "arg=read&repo_branch=" . rawurlencode($branch) . $ref_query .
            "&repo_view=" . $kind);
        $data["GIT_LIST_SCROLL_URL"] = $prefix .
            "arg=read&repo_branch=" . rawurlencode($branch) . $ref_query .
            "&repo_view=" . $kind . $sort_query . "&repo_rows=1";
        $data["GIT_LIST_PAGE_SIZE"] = $page_size;
        /* The list is filled in as the reader scrolls it, which basic.js
           does. That file is loaded at the end of the layout, after the
           page's own content, so a script tag written into the list
           itself would run before the function it calls exists and the
           reader would see only the first page of rows. What is put here
           is written out after basic.js, so the function is there to be
           called. */
        $data['SCRIPT'] ??= "";
        $data['SCRIPT'] .= 'gitInfiniteScroll("git-list-box", ' .
            '"git-list-rows", "' . $data["GIT_LIST_SCROLL_URL"] . '", ' .
            $page_size . ');';
    }
    /**
     * gitStreamArchive builds a able to be downloaded archive of one snapshot
     * of the
     * repository and sends it to the reader's browser. The archive is written
     * to a temporary file rather than built up in memory, streamed back with a
     * name made from the page and the short commit name, and then removed. A
     * build that is refused, for instance because the snapshot is too large to
     * archive, is answered with a server-error status instead.
     * @param object $repository the repository being read
     * @param string $view_commit object name of the commit to archive
     * @param string $page_name the wiki page name, used to name the file
     * @param string $format either "zip" or "targz"
     */
    private function gitStreamArchive($repository, $view_commit, $page_name,
        $format)
    {
        $parent = $this->parent;
        $format = ($format === "zip") ? "zip" : "targz";
        $extension = ($format === "zip") ? ".zip" : ".tar.gz";
        $content_type = ($format === "zip") ? "application/zip" :
            "application/gzip";
        $safe_name = preg_replace("/[^A-Za-z0-9._-]/", "_", $page_name);
        $short = substr($view_commit, 0, C\GIT_SHORT_HASH_LENGTH);
        $temp_dir = C\TEMP_DIR . "/";
        if (!file_exists($temp_dir)) {
            mkdir($temp_dir);
        }
        $temp_path = $temp_dir . "git_archive_" .
            L\crawlHash($view_commit . microtime() .
            random_int(0, PHP_INT_MAX)) . $extension;
        try {
            $repository->writeArchive($view_commit, $format, $temp_path,
                $safe_name . "/");
        } catch (\Exception $error) {
            @unlink($temp_path);
            $parent->web_site->header("HTTP/1.1 500 Internal Server Error");
            \seekquarry\atto\webExit();
            return;
        }
        $this->gitSendDownloadFile($temp_path, $content_type,
            $safe_name . "-" . $short . $extension);
    }
    /**
     * gitStreamResource sends the one file or folder the reader is looking at
     * to their browser. A file is served as its raw bytes to save; a folder is
     * packaged into a tar.gz or zip archive of just that folder's subtree,
     * built to a temporary file and streamed back the same way a whole
     * repository archive is. A build that is refused is answered with a server-
     * error status instead.
     * @param object $repository the repository being read
     * @param array $file_entry the file being viewed, or null on a folder view
     * @param string $tree_sha object name of the folder's tree, used when
     *     archiving a folder
     * @param string $view_commit object name of the snapshot being read
     * @param string $page_name the wiki page name, used to name downloads
     * @param string $current_path folder path being viewed, used to name a
     *     folder download
     * @param string $format "raw" for a file, else "zip" or "targz"
     */
    private function gitStreamResource($repository, $file_entry, $tree_sha,
        $view_commit, $page_name, $current_path, $format)
    {
        $parent = $this->parent;
        if ($file_entry !== null) {
            $blob = $repository->blob($file_entry["sha"]);
            $safe_name = preg_replace("/[^A-Za-z0-9._-]/", "_",
                $file_entry["name"]);
            $parent->web_site->header("HTTP/1.1 200 OK");
            $parent->web_site->header(
                "Content-Type: application/octet-stream");
            $parent->web_site->header("Content-Disposition: attachment; " .
                "filename=\"" . $safe_name . "\"");
            $parent->web_site->header("Content-Length: " . strlen($blob));
            echo $blob;
            \seekquarry\atto\webExit();
            return;
        }
        $format = ($format === "zip") ? "zip" : "targz";
        $extension = ($format === "zip") ? ".zip" : ".tar.gz";
        $content_type = ($format === "zip") ? "application/zip" :
            "application/gzip";
        $safe_name = preg_replace("/[^A-Za-z0-9._-]/", "_", $page_name);
        if ($current_path !== "") {
            $safe_name .= "_" . preg_replace("/[^A-Za-z0-9._-]/", "_",
                $current_path);
        }
        $temp_dir = C\TEMP_DIR . "/";
        if (!file_exists($temp_dir)) {
            mkdir($temp_dir);
        }
        $temp_path = $temp_dir . "git_archive_" . L\crawlHash(
            $view_commit . $current_path . microtime() .
            random_int(0, PHP_INT_MAX)) . $extension;
        try {
            $repository->writeArchiveFromTree($tree_sha, $format,
                $temp_path, $safe_name . "/");
        } catch (\Exception $error) {
            @unlink($temp_path);
            $parent->web_site->header(
                "HTTP/1.1 500 Internal Server Error");
            \seekquarry\atto\webExit();
            return;
        }
        $this->gitSendDownloadFile($temp_path, $content_type,
            $safe_name . $extension);
    }
    /**
     * gitCommitDiff builds the changed-file diff of one commit for the read
     * view. Each file the commit added, removed, or changed is listed with a
     * line by line comparison of its earlier and later contents; a binary file
     * or one too large to show is named without a body. The rendered diff and
     * the commit's own details are handed to the view already escaped.
     * @param array &$data view data to add the diff fields to
     * @param object $repository the repository being read
     * @param string $view_commit object name of the commit to show
     * @param string $prefix start of every read-view link
     * @param string $branch name of the branch currently chosen
     * @param string $ref_query the ref part of read-view links
     */
    private function gitCommitDiff(&$data, $repository, $view_commit,
        $prefix, $branch, $ref_query)
    {
        $parent = $this->parent;
        try {
            $changes = $repository->treeDiff($view_commit);
            $commit = $repository->commit($view_commit);
        } catch (\Exception $error) {
            $changes = [];
            $commit = ["author" => "", "message" => ""];
        }
        $files_html = "";
        foreach ($changes as $change) {
            $old_blob = ($change["old_sha"] === "") ? "" :
                $repository->blob($change["old_sha"]);
            $new_blob = ($change["new_sha"] === "") ? "" :
                $repository->blob($change["new_sha"]);
            if (strlen($old_blob) > C\MAX_GIT_BLOB_VIEW_LEN ||
                strlen($new_blob) > C\MAX_GIT_BLOB_VIEW_LEN) {
                $body = "<p class='git-note'>" .
                    tl("wiki_element_git_too_large") . "</p>";
            } else if (str_contains($old_blob, "\0") ||
                str_contains($new_blob, "\0")) {
                $body = "<p class='git-note'>" .
                    tl("wiki_element_git_binary") . "</p>";
            } else {
                $body = "<div class='git-diff-body'>" .
                    L\diff($parent->clean($old_blob, "string"),
                    $parent->clean($new_blob, "string"), true) . "</div>";
            }
            $files_html .= "<div class='git-diff-file'>" .
                "<h4 class='git-diff-" . $change["status"] . "'>" .
                $parent->clean($change["path"], "string") . "</h4>" .
                $body . "</div>";
        }
        $data["GIT_VIEW"] = "diff";
        $data["GIT_DIFF_FILES"] = $files_html;
        $data["GIT_DIFF_SUBJECT"] = $parent->clean(
            strtok($commit["message"], "\n"), "string");
        $data["GIT_DIFF_AUTHOR"] = $parent->clean($commit["author"],
            "string");
        $data["GIT_DIFF_EMPTY"] = empty($changes);
    }
    /**
     * gitReadmeToc pulls the list of section headings out of a rendered README
     * so the read view can offer a contents menu that jumps to a section. Every
     * heading is given a plain, predictable id as it is scanned, and the
     * rendered README passed in is updated in place to carry those ids, so each
     * menu entry can be an ordinary link to its heading that works without any
     * script. Each returned entry keeps the heading level (so the menu can
     * indent subsections), the plain text for the menu label, and the link
     * target. An empty list is returned when the README has at most one
     * heading, since a contents menu would not help then.
     * @param string $readme_html the rendered README, updated in place to give
     *     each heading the id its menu entry links to
     * @return array list of headings, each with LEVEL, TEXT, and URL
     */
    private function gitReadmeToc(&$readme_html)
    {
        $headings = [];
        $index = 0;
        $readme_html = preg_replace_callback(
            "/<h([1-6])([^>]*)>(.*?)<\/h\\1>/s",
            function ($matches) use (&$headings, &$index) {
                $anchor = "git-heading-" . $index;
                $text = trim(strip_tags($matches[3]));
                $headings[] = ["LEVEL" => (int)$matches[1],
                    "TEXT" => ($text === "") ? "&hellip;" : $text,
                    "URL" => "#" . $anchor];
                $index++;
                $attributes = preg_replace("/\s+id='[^']*'/", "",
                    $matches[2]);
                return "<h{$matches[1]}{$attributes} id='{$anchor}'>" .
                    "{$matches[3]}</h{$matches[1]}>";
            }, $readme_html);
        return (count($headings) > 1) ? $headings : [];
    }

    /**
     * addContact contains the logic need to add a contact $data['CONTACT_ID']
     * to the list of $user_id's contacts for messaging.
     * @param int $user_id id of user adding contact to
     * @param array $data current data to be sent to view after processing needs
     *     to contain a field $data['CONTACT_ID'] with the contact_id of user to
     *     add to contacts
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    protected function addContact($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $user_model = $parent->model("user");
        $contact_id = $data['CONTACT_ID'] ?? "";
        if (empty($contact_id)) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_contact'));
        }
        if ($user_id == C\PUBLIC_USER_ID || $user_id == $contact_id) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_user'));
        }
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        $contact_messages_id = $group_model->getPersonalGroupId($contact_id);
        if (!empty($contact_id) &&
            !in_array($user_model->getUserStatus($contact_id), [
                C\ACTIVE_STATUS, C\EDITOR_STATUS]) ) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_contact'));
        }
        $this->addUserGroupWithinLimits($contact_id, $user_messages_id);
        $messages_thread_title = $feed_model->getMessagesThreadTitle(
            [$user_id, $contact_id]);
        $_REQUEST["contact_id"] = $contact_id;
        if ($group_model->isUserIdInContacts($user_id,
            $contact_id)) {
            $existing_parent_id =
                $feed_model->getGroupThreadId(
                $contact_messages_id, $contact_id,
                $messages_thread_title);
            $this->addGroupItemWithinLimits(
                $existing_parent_id, $user_messages_id, $user_id,
                $messages_thread_title, "");
            $request_thread_title = $feed_model->getMessagesThreadTitle(
                [$user_id]);
            $request_thread_id = $feed_model->getGroupThreadId(
                $user_messages_id, $contact_id, $request_thread_title);
            if (!empty($request_thread_id)) {
                $feed_model->deleteGroupItem($request_thread_id, $contact_id);
            }
            return $parent->redirectWithMessage(
                tl('social_component_connection_established'), ["contact_id"]);
        } else {
            $this->addGroupItemWithinLimits(0,
                $user_messages_id, $user_id, $messages_thread_title,
                "");
            $blocked_thread_title = $feed_model->getMessagesThreadTitle(
                [$user_id, "blocked"]);
            $blocked_thread_id = $feed_model->getGroupThreadId(
                $user_messages_id, $contact_id, $blocked_thread_title);
            if (!$blocked_thread_id) {
                $request_thread_title = $feed_model->getMessagesThreadTitle(
                    [$contact_id]);
            }
            // thread used to keep tract of contact requests for $contact_id
            $this->addGroupItemWithinLimits(0,
                $contact_messages_id, $user_id, $request_thread_title,
                "");
            return $parent->redirectWithMessage(
                tl('social_component_connection_requested'), ["contact_id"]);
        }
    }
    /**
     * handlePageIconUpload saves an uploaded wiki page icon as a thumbnail
     * under the page's resource folder, after validating the upload's MIME type
     * and size.
     * @param int $group_id group the page belongs to
     * @param int $page_id wiki page id whose icon is being updated
     * @param array $preserve_fields query-string fields to carry through the
     *     redirect (csrf, current sub-path, and so on) on validation failures
     * @return mixed redirectWithMessage result on validation failure; void on
     *     the success path
     */
    public function handlePageIconUpload($group_id, $page_id, $preserve_fields)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        if (!in_array($_FILES['page_icon']['type'],
            ['image/png', 'image/gif', 'image/jpeg',
                'image/webp', 'image/x-icon'])) {
            return $parent->redirectWithMessage(
                tl('social_component_unknown_imagetype'),
                $preserve_fields);
        }
        if ($_FILES['page_icon']['size'] > C\THUMB_SIZE) {
            return $parent->redirectWithMessage(
                tl('social_component_icon_too_big'),
                 $preserve_fields);
        }
        if (empty($_FILES['page_icon']['data'])) {
            $image_string = file_get_contents(
                $_FILES['page_icon']['tmp_name']);
        } else {
            $image_string = $_FILES['page_icon']['data'];
        }
        $icon_path =
            $wiki_model->getGroupPageIconPath($group_id, $page_id);
        $image = @imagecreatefromstring($image_string);
        $thumb_string = ImageProcessor::createThumb($image);
        if (!empty($icon_path) && !empty($thumb_string)) {
            /* The path already names the file, so nothing is added to it
               here: appending an ending wrote the thumbnail beside the
               name a reader asks for rather than at it. */
            file_put_contents($icon_path, $thumb_string);
            clearstatcache(true, $icon_path);
        }
        /* The picture itself is kept among the page's resources, at the
           size it was given. Only the small copy used to stand for the
           page was being kept, so a banner meant to be seen at the head
           of an article existed nowhere at its own size. */
        $folders = $wiki_model->getGroupPageResourcesFolders($group_id,
            $page_id, "", true);
        if (is_array($folders) && !empty($folders[0])) {
            $endings = ["image/png" => ".png", "image/gif" => ".gif",
                "image/jpeg" => ".jpg", "image/webp" => ".webp",
                "image/x-icon" => ".ico"];
            $ending = $endings[$_FILES['page_icon']['type'] ?? ""] ??
                ".png";
            file_put_contents($folders[0] . "/" . self::BANNER_NAME .
                $ending, $image_string);
            clearstatcache(true, $folders[0] . "/" . self::BANNER_NAME .
                $ending);
        }
        return $parent->redirectWithMessage(
            tl("social_component_page_saved"), $preserve_fields);
    }
    /**
     * outputPodcastStatus emits, as JSON, whether a podcast download is
     * currently in progress for a media-list page's folder, then ends the
     * response. The Media List view polls this to switch between a live
     * downloading indicator and the update button without reloading the page.
     * @param int $group_id group of the page being polled
     * @param int $page_id wiki page being polled
     * @param string $sub_path resource sub-folder being polled, empty for the
     *     page's top resource folder
     */
    protected function outputPodcastStatus($group_id, $page_id,
        $sub_path)
    {
        $parent = $this->parent;
        $folder_key =
            LMJ\PodcastDownloadJob::podcastFolderKey(
            $group_id, $page_id, $sub_path);
        $state = $this->podcastFolderState($folder_key);
        $parent->web_site->header("Content-Type: application/json");
        e(json_encode(["state" => $state]));
        \seekquarry\atto\webExit();
    }
    /**
     * editWikiResourceFile opens one file kept beside a wiki page for
     * writing, and saves it where the writer sent one back.
     *
     * editWiki calls this where the request names a file rather than
     * the page itself. Only the kinds of file a person can read as
     * text are opened; anything else is left alone, since showing its
     * bytes in a writing box would ruin it. A save is refused where
     * somebody else has written to the file since this writer opened
     * it, and the writer is told so rather than losing what they
     * typed. A save may name a file of its own, which writes what is
     * on screen as a new file in the same folder and leaves the file
     * that was open as it was; a name already in the folder is refused
     * rather than written over.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the page belongs to
     * @param array $page_info what the model holds about the page
     * @param string $page_name name of the page the file sits beside
     * @param string $page what the writer typed, or null where they
     *     are only opening the file
     * @param string $sub_path folder under the page the file sits in
     * @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 file is drawn as usual
     */
    protected function editWikiResourceFile(&$data, $group_id,
        $page_info, $page_name, $page, $sub_path, $preserve_fields)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $file_name = $parent->clean(urldecode($_REQUEST['n']),
            "file_name");
        $name_parts = pathinfo($file_name);
        $extension = strtolower($name_parts['extension'] ?? "");
        $is_image = in_array($extension, C\EDITABLE_IMAGE_EXTENSIONS);
        if (!empty($name_parts['extension']) &&
            in_array($name_parts['extension'],
            C\EDITABLE_RESOURCE_EXTENSIONS)) {
            $extension = $name_parts['extension'];
            $data['RAW'] = !empty($_REQUEST['download']);
            $data['PAGE'] = $wiki_model->getPageResource(
                $file_name, $group_id, $page_info['ID'], $sub_path,
                $data['RAW']);
            if (empty($data['RAW']) && $name_parts['extension'] != 'csv') {
                $data['PAGE'] = htmlentities($data['PAGE']);
            }
        } else if ($is_image) {
            $data['PAGE'] = "";
        } else {
            $data['PAGE'] = false;
        }
        if ($is_image && isset($_REQUEST['image_editor_data'])) {
            return $this->saveWikiImageResource($data, $parent,
                $wiki_model, $file_name, $group_id, $page_info,
                $sub_path, $page_name, $preserve_fields);
        }
        if ($page !== null && $data['PAGE'] !== false) {
            $action = "wikiupdate_group=$group_id" .
                "&page=" . $page_name . "&resource_name=" . $file_name;
            if (!$parent->checkCSRFTime(C\p('CSRF_TOKEN'), $action)) {
                $data['SCRIPT'] .= "doMessage('<h1 class=\"red\" >".
                    tl('social_component_wiki_edited_elsewhere').
                    "</h1>');";
                return;
            }
            $preserve_fields[] = 'n';
            $written_name = $this->saveAsResourceName($parent, $file_name);
            if ($written_name != $file_name &&
                $wiki_model->hasPageResource($written_name, $group_id,
                $page_info['ID'], $sub_path)) {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_name_taken',
                    $written_name), $preserve_fields);
            }
            $success = $wiki_model->setPageResource($written_name,
                $_REQUEST['page'], $group_id, $page_info['ID'],
                $sub_path);
            $_REQUEST['n'] = $written_name;
            if ($success) {
                $preserve_fields[] = 'back_params';
                return $parent->redirectWithMessage(
                    tl("social_component_resource_saved"),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_saved'),
                    $preserve_fields);
            }
        }
        $data['PAGE_ID'] = $page_info['ID'];
        $data['PAGE_NAME'] = $page_name;
        $data['RESOURCE_NAME'] = $file_name;
        $page_head = WikiParser::parsePageHeadVars($page_info['PAGE'] ?? "");
        $data['PAGE_LISTS_FILES'] =
            ($page_head['page_type'] ?? "") == 'media_list';
        /* The address that hands the file down rather than drawing it,
           which is what a reader of the file is given. The editing
           screen's download mark uses the same one, so the mark does the
           same thing wherever it stands. */
        $data['RESOURCE_DOWNLOAD_URL'] =
            $wiki_model->getGroupPageResourceUrl(
            $parent->generateCSRFToken($_SESSION['USER_ID'] ??
            C\PUBLIC_USER_ID),
            $group_id, $page_info['ID'] ?? 0, $file_name, $sub_path);
        $data['RESOURCE_DOWNLOAD_URL'] .=
            (strpos($data['RESOURCE_DOWNLOAD_URL'], "?") === false) ?
            "?download=true" : "&amp;download=true";
    }
    /**
     * saveWikiImageResource writes the picture a canvas editor hands
     * back over the file it was opened from.
     *
     * editWikiResourceFile calls this when the open file is a picture and
     * the form carries the image_editor_data field. That field holds the
     * canvas read as a data address, whose head names the kind and whose
     * body is the picture in base sixty-four. The body is decoded to the
     * picture's bytes and written under the name the file had, or under
     * the name a Save As gave, the same way any resource is written. A
     * field that is empty or does not hold a picture writes nothing and
     * says so.
     *
     * @param array $data fields prepared for the page, added to with the
     *     message the writer is redirected with
     * @param object $parent the controller this component sits on
     * @param object $wiki_model the model that reads and writes a page's
     *     files
     * @param string $file_name name the picture was opened under
     * @param int $group_id which group's page holds the picture
     * @param array $page_info the row for the page the picture is kept
     *     with, read for its identifier
     * @param string $sub_path folder under the page the picture is in
     * @param string $page_name name of the page the picture is kept with
     * @param array $preserve_fields form fields carried across the
     *     redirect that follows the save
     * @return mixed the controller's redirect, or nothing where the
     *     field held no picture
     */
    public function saveWikiImageResource($data, $parent, $wiki_model,
        $file_name, $group_id, $page_info, $sub_path, $page_name,
        $preserve_fields)
    {
        $address = $_REQUEST['image_editor_data'] ?? "";
        $comma = strpos($address, ",");
        if ($comma === false || strpos($address, "data:image/") !== 0) {
            return $parent->redirectWithMessage(
                tl('social_component_resource_not_saved'),
                $preserve_fields);
        }
        $bytes = base64_decode(substr($address, $comma + 1), true);
        if ($bytes === false) {
            return $parent->redirectWithMessage(
                tl('social_component_resource_not_saved'),
                $preserve_fields);
        }
        $preserve_fields[] = 'n';
        $written_name = $this->saveAsResourceName($parent, $file_name);
        if ($written_name != $file_name &&
            $wiki_model->hasPageResource($written_name, $group_id,
            $page_info['ID'], $sub_path)) {
            return $parent->redirectWithMessage(
                tl('social_component_resource_name_taken',
                $written_name), $preserve_fields);
        }
        $success = $wiki_model->setPageResource($written_name, $bytes,
            $group_id, $page_info['ID'], $sub_path);
        $_REQUEST['n'] = $written_name;
        if ($success) {
            $preserve_fields[] = 'back_params';
            return $parent->redirectWithMessage(
                tl("social_component_resource_saved"), $preserve_fields);
        }
        return $parent->redirectWithMessage(
            tl('social_component_resource_not_saved'), $preserve_fields);
    }
    /**
     * saveAsResourceName works out which file name a save of an open
     * resource should be written under.
     *
     * editWikiResourceFile calls this before it writes. A writer who
     * turns off saving as they type is offered a Save As mark beside
     * the Save mark, and the name they type in its dialog arrives as
     * the save_as_name field. Where that field is empty, or holds the
     * name already open, the file keeps the name it had. Only the last
     * part of what was typed is used, so a name carrying folder marks
     * cannot write outside the folder the writer is looking at.
     *
     * @param object $parent the controller this component sits on,
     *     used to clean what the request carries
     * @param string $file_name name the file was opened under
     * @return string name to write the file under
     */
    public function saveAsResourceName($parent, $file_name)
    {
        $said = $parent->clean($_REQUEST['save_as_name'] ?? "",
            "file_name");
        $said = trim(pathinfo($said, PATHINFO_BASENAME));
        return ($said === "") ? $file_name : $said;
    }
    /**
     * editWikiResourceAction carries out whatever a writer asked of
     * the files kept beside a wiki page: copying, cutting, pasting,
     * moving, renaming, unpacking, describing, or making a new one.
     *
     * editWiki calls this once it knows which page is being worked
     * on. Each action answers with a line saying what happened and
     * sends the writer back to the folder they were looking at. Any
     * of them turns off the upload the same request might carry, so
     * a file is not written twice over.
     *
     * @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 array $page_info what the model holds about the page
     * @param string $page_name name of the page the files sit beside
     * @param string $sub_path folder under the page the writer is in
     * @param array $preserve_fields fields a redirect carries so the
     *     writer comes back to the screen they were on
     * @param bool &$upload_allowed set to false where an action ran,
     *     so the same request does not also take an upload
     * @return mixed what is handed back to the browser, or null where
     *     no action was asked for
     */
    protected function editWikiResourceAction(&$data, $user_id,
        $group_id, $group, $page_info, $page_name, $sub_path,
        $preserve_fields, &$upload_allowed)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if (isset($_REQUEST['empty_clip'])) {
            $upload_allowed = false;
            if ($wiki_model->emptyClipFolder($user_id)) {
                return $parent->redirectWithMessage(
                    tl('social_component_clipboard_emptied'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_clipboard_not_emptied'),
                    $preserve_fields);
            }
        } else if (isset($_REQUEST['paste_all'])) {
            $upload_allowed = false;
            if ($wiki_model->pasteAllClipFolder($user_id, $group_id,
                $page_info['ID'], $sub_path)) {
                return $parent->redirectWithMessage(
                    tl('social_component_paste_all_success'), $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_paste_all_failed'), $preserve_fields);
            }
        } else if (!empty($_REQUEST['move_resource']) &&
            isset($_REQUEST['move_to_path'])) {
            $upload_allowed = false;
            $move_resource = $parent->clean($_REQUEST['move_resource'],
                "file_name");
            $move_to_path = $parent->clean($_REQUEST['move_to_path'],
                'path');
            if (isset($page_info['ID']) &&
                $wiki_model->moveResourceToSubPath($move_resource,
                $move_to_path, $group_id, $page_info['ID'], $sub_path)) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_moved'));
                return $parent->redirectWithMessage(
                    tl('social_component_resource_moved'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_moved'),
                    $preserve_fields);
            }
        } else if (!empty($_REQUEST['delete_resources'])) {
            $upload_allowed = false;
            $delete_names = $this->requestResourceNames($parent,
                'delete_resources');
            $deleted_count = 0;
            foreach ($delete_names as $delete_name) {
                if (isset($page_info['ID']) &&
                    $wiki_model->deleteResource($delete_name, $group_id,
                    $page_info['ID'], $sub_path)) {
                    $deleted_count++;
                }
            }
            if ($deleted_count > 0) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_deleted'));
                return $parent->redirectWithMessage(
                    tl('social_component_resource_deleted'),
                    $preserve_fields);
            }
            return $parent->redirectWithMessage(
                tl('social_component_resource_not_deleted'),
                $preserve_fields);
        } else if (!empty($_REQUEST['move_resources']) &&
            isset($_REQUEST['move_to_path'])) {
            $upload_allowed = false;
            $move_names = $this->requestResourceNames($parent,
                'move_resources');
            $move_to_path = $parent->clean($_REQUEST['move_to_path'],
                'path');
            $moved_count = 0;
            foreach ($move_names as $move_name) {
                if (isset($page_info['ID']) &&
                    $wiki_model->moveResourceToSubPath($move_name,
                    $move_to_path, $group_id, $page_info['ID'],
                    $sub_path)) {
                    $moved_count++;
                }
            }
            return $this->reportResourcesMoved($parent, $group_model,
                $moved_count, $user_id, $page_info, $preserve_fields);
        } else if (!empty($_REQUEST['move_resources']) &&
            !empty($_REQUEST['move_target'])) {
            $upload_allowed = false;
            $move_names = $this->requestResourceNames($parent,
                'move_resources');
            $move_target = $parent->clean($_REQUEST['move_target'],
                "file_name");
            $moved_count = 0;
            foreach ($move_names as $move_name) {
                if (isset($page_info['ID']) &&
                    $wiki_model->moveResourceToFolder($move_name,
                    $move_target, $group_id, $page_info['ID'],
                    $sub_path)) {
                    $moved_count++;
                }
            }
            return $this->reportResourcesMoved($parent, $group_model,
                $moved_count, $user_id, $page_info, $preserve_fields);
        } else if (!empty($_REQUEST['clip_copy_resources'])) {
            $upload_allowed = false;
            $copy_names = $this->requestResourceNames($parent,
                'clip_copy_resources');
            $wiki_model->emptyClipFolder($user_id);
            foreach ($copy_names as $copy_name) {
                if (!$wiki_model->copyResourceToClipFolder($user_id,
                    $copy_name, $group_id, $page_info['ID'], $sub_path)) {
                    return $parent->redirectWithMessage(
                        tl('social_component_copy_fail'), $preserve_fields);
                }
            }
            return $parent->redirectWithMessage(
                tl('social_component_copy_success'), $preserve_fields);
        } else if (!empty($_REQUEST['clip_cut_resources'])) {
            $upload_allowed = false;
            $cut_names = $this->requestResourceNames($parent,
                'clip_cut_resources');
            $wiki_model->emptyClipFolder($user_id);
            foreach ($cut_names as $cut_name) {
                if (!$wiki_model->moveResourceToClipFolder($user_id,
                    $cut_name, $group_id, $page_info['ID'], $sub_path)) {
                    return $parent->redirectWithMessage(
                        tl('social_component_clip_cut_fail'),
                        $preserve_fields);
                }
            }
            $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                tl('social_component_clip_cut_success'));
            $_REQUEST['reset_detail'] = "true";
            return $parent->redirectWithMessage(
                tl('social_component_cut_success'), array_merge(
                    ["reset_detail"], $preserve_fields));
        } else if (!empty($_REQUEST['delete_resource'])) {
            $upload_allowed = false;
            $delete_resource = $parent->clean(
                $_REQUEST['delete_resource'], "file_name");
            if (isset($page_info['ID']) &&
                $wiki_model->deleteResource($delete_resource,
                $group_id, $page_info['ID'], $sub_path)) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_deleted'));
                return $parent->redirectWithMessage(
                    tl('social_component_resource_deleted'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_deleted'),
                    $preserve_fields);
            }
        } else if (!empty($_REQUEST['move_resource']) &&
            !empty($_REQUEST['move_target'])) {
            $upload_allowed = false;
            $move_resource = $parent->clean($_REQUEST['move_resource'],
                "file_name");
            $move_target = $parent->clean($_REQUEST['move_target'],
                "file_name");
            if (isset($page_info['ID']) &&
                $wiki_model->moveResourceToFolder($move_resource,
                $move_target, $group_id, $page_info['ID'], $sub_path)) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_moved'));
                return $parent->redirectWithMessage(
                    tl('social_component_resource_moved'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_moved'),
                    $preserve_fields);
            }
        } else if (isset($_REQUEST['paste'])) {
            $resource_name = $parent->clean($_REQUEST['paste'],
                "string");
            $upload_allowed = false;
            if (!$wiki_model->pasteFromClipFolder(
                $user_id, $resource_name, $group_id,
                $page_info['ID'], $sub_path)) {
                return $parent->redirectWithMessage(
                    tl('social_component_paste_fail'), $preserve_fields);
            }
            return $parent->redirectWithMessage(
                tl('social_component_paste_success'), $preserve_fields);
        } else if (isset($_REQUEST['clip_copy'])) {
            $resource_name = $parent->clean($_REQUEST['clip_copy'],
                "string");
            $upload_allowed = false;
            if (!$wiki_model->copyResourceToClipFolder(
                $user_id, $resource_name, $group_id,
                $page_info['ID'], $sub_path)) {
                return $parent->redirectWithMessage(
                    tl('social_component_copy_fail'), $preserve_fields);
            }
            return $parent->redirectWithMessage(
                tl('social_component_copy_success'), $preserve_fields);
        } else if (isset($_REQUEST['clip_cut'])) {
            $upload_allowed = false;
            $resource_name = $parent->clean($_REQUEST['clip_cut'],
                "string");
            if (!$wiki_model->moveResourceToClipFolder(
                $user_id, $resource_name, $group_id,
                $page_info['ID'], $sub_path)) {
                return $parent->redirectWithMessage(
                    tl('social_component_clip_cut_fail'), $preserve_fields);
            }
            $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                tl('social_component_clip_cut_success'));
            $_REQUEST['reset_detail'] = "true";
            return $parent->redirectWithMessage(
                tl('social_component_cut_success'), array_merge(
                    ["reset_detail"], $preserve_fields));
        } else if (isset($_REQUEST['extract'])) {
            $resource_name = $parent->clean($_REQUEST['extract'],
                "string");
            $upload_allowed = false;
            if (isset($page_info['ID']) &&
                $wiki_model->extractResource($resource_name,
                $group_id, $page_info['ID'], $sub_path)) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_extracted'));
                return $parent->redirectWithMessage(
                    tl('social_component_resource_extracted'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_extracted'),
                    $preserve_fields);
            }
        } else if (!empty($_REQUEST['new_resource_name']) &&
            !empty($_REQUEST['old_resource_name'])) {
            $upload_allowed = false;
            $old_resource_name = $parent->clean(
                $_REQUEST['old_resource_name'], "file_name");
            $new_resource_name = $parent->clean(
                $_REQUEST['new_resource_name'], "file_name");
            if (isset($page_info['ID']) &&
                $wiki_model->renameResource($old_resource_name,
                    $new_resource_name, $group_id,
                    $page_info['ID'], $sub_path)) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_renamed'));
                return $parent->redirectWithMessage(
                    tl('social_component_resource_renamed'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_renamed'),
                    $preserve_fields);
            }
        } else if (isset($_REQUEST['resource_description'])) {
            $resource_description = $parent->clean(
                $_REQUEST['resource_description'], "string");
            if (!($resource_name = $parent->clean(urldecode($_REQUEST['n']??""),
                "file_name"))) {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_description_file_error'),
                    $preserve_fields);
            }
            $success = $wiki_model->setResourceDescription($resource_name,
                $resource_description, $group_id, $page_info['ID'],
                $sub_path ?? "");
            if ($data['TARGET'] == 'child') {
                $_REQUEST['arg'] = 'media-detail-edit';
                $_REQUEST['resources'] = 'false';
                $_REQUEST['page_id'] = $page_info['ID'];
                $preserve_fields[] = 'n';
                $preserve_fields[] = 'page_id';
                $preserve_fields[] = 'resources';
                $preserve_fields[] = 'target';
            }
            if ($success) {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_description_saved'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_description_error'),
                    $preserve_fields);
            }
        } else if (isset($_REQUEST['resource_actions']) &&
            in_array($_REQUEST['resource_actions'],
            ['clear-lock', 'version']) && !empty($page_info['ID']) &&
            ((isset($group['OWNER_ID']) &&
            $group['OWNER_ID'] == $user_id) ||
            $user_id == C\ROOT_ID)) {
            if ($_REQUEST['resource_actions'] == 'clear-lock') {
                $done = $wiki_model->clearGroupPageResourceLock(
                    $group_id, $page_info['ID'], $sub_path);
                $message = ($done) ?
                    tl('social_component_resource_lock_cleared') :
                    tl('social_component_resource_lock_not_cleared');
            } else {
                $done = $wiki_model->versionGroupPageResource(
                    $group_id, $page_info['ID'], $sub_path);
                $message = ($done) ?
                    tl('social_component_resource_versioned') :
                    tl('social_component_resource_not_versioned');
            }
            return $parent->redirectWithMessage($message,
                $preserve_fields);
        } else if (isset($_REQUEST['resource_actions']) &&
            $_REQUEST['resource_actions'] == 'zip' &&
            !empty($page_info['ID'])) {
            $folders = $wiki_model->getGroupPageResourcesFolders(
                $group_id, $page_info['ID'], $sub_path);
            $folder = (empty($folders[0])) ? "" : $folders[0];
            $thumb_folder = (empty($folders[1])) ? "" : $folders[1];
            if ($folder == "" || !is_dir($folder)) {
                return $parent->redirectWithMessage(
                    tl('social_component_no_resources_to_zip'),
                    $preserve_fields);
            }
            if ($this->folderContentsLen($folder) +
                $this->folderContentsLen($thumb_folder) >
                C\MAX_RESOURCES_ZIP_LEN) {
                return $parent->redirectWithMessage(
                    tl('social_component_resources_zip_too_big',
                    L\intToMetric(C\MAX_RESOURCES_ZIP_LEN)),
                    $preserve_fields);
            }
            if (!$this->streamResourcesZip($folder, $thumb_folder,
                $page_name)) {
                return $parent->redirectWithMessage(
                    tl('social_component_resources_zip_failed'),
                    $preserve_fields);
            }
            \seekquarry\atto\webExit();
        } else if (isset($_REQUEST['resource_actions']) &&
            in_array($_REQUEST['resource_actions'],
            ['new-folder', 'new-text-file', 'new-csv-file',
            'new-image-file']) &&
            !empty($page_info['ID'])) {
            $wide = intval($_REQUEST['image_wide'] ?? 0);
            $tall = intval($_REQUEST['image_tall'] ?? 0);
            $made = $wiki_model->newResource($_REQUEST['resource_actions'],
                $group_id, $page_info['ID'], $sub_path, $wide, $tall);
            if ($made) {
                $wiki_model->versionGroupPage($user_id, $page_info['ID'],
                    tl('social_component_resource_created'));
                if ($_REQUEST['resource_actions'] == 'new-image-file' &&
                    is_string($made)) {
                    $_REQUEST['n'] = $made;
                    unset($_REQUEST['resources']);
                    $preserve_fields[] = 'n';
                }
                return $parent->redirectWithMessage(
                    tl('social_component_resource_created'),
                    $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_resource_not_created'),
                    $preserve_fields);
            }
        }
    }
    /**
     * editWikiUploadResource takes a file a writer sent up and puts
     * it in the folder beside the page.
     *
     * editWiki calls this last, after the actions that act on files
     * already there. A page that has never been saved has no folder
     * to put anything in, so the writer is asked to save it first. A
     * page whose text came up with the file is saved as well, so the
     * file it names is found when the page is next drawn.
     *
     * @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 $page_info what the model holds about the page
     * @param string $page_name name of the page the file sits beside
     * @param string $page what the writer typed, where they typed
     *     anything
     * @param string $sub_path folder under the page the file goes 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 $preserve_fields fields a redirect carries so the
     *     writer comes back to the screen they were on
     * @param bool $upload_allowed false where an action on the files
     *     already there has run, in which case no file is taken
     * @return mixed what is handed back to the browser, or null where
     *     no file came up
     */
    protected function editWikiUploadResource(&$data, $user_id,
        $group_id, $page_info, $page_name, $page, $sub_path,
        $edit_reason, $read_address, $preserve_fields, $upload_allowed)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if ($upload_allowed && !empty($_FILES['page_resource']['name'])) {
            if (!isset($page_info['ID'])) {
                $_FILES = [];
                return $parent->redirectWithMessage(
                    tl('social_component_resource_save_first'),
                    $preserve_fields);
            }
            $result = $this->handleResourceUploads(
                $group_id, $page_info['ID'], $sub_path);
            if ($result == self::UPLOAD_SUCCESS) {
                //we re-parse page so resources parsed
                if (isset($page) && isset($edit_reason)) {
                    $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);
                }
                return $parent->redirectWithMessage(
                    tl('social_component_resource_uploaded'), $preserve_fields);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_upload_error'), $preserve_fields);
            }
        }
    }
    /**
     * Used to set-up information for drawing the mediaWikiDetail of a media
     * resource page
     *
     * @param array &$data array of field variables for view will be modified
     *  by this function
     * @param int $group_id id of group wiki page belongs to
     * @param int $page_id id of wiki page
     * @param string $sub_path sub-resource folder that is being used, if any,
     *  that resource is from
     */
    public function mediaWikiDetail(&$data, $group_id, $page_id, $sub_path = "")
    {
        if (!isset($page_id)) {
            return;
        }
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if (empty($_REQUEST['n'])) {
            $sub_parts = explode("/", $sub_path);
            $media_name = array_pop($sub_parts);
            $sub_path = implode("/", $sub_parts);
        } else {
            $media_name = $parent->clean($_REQUEST['n'], "file_name");
        }
        $page_info = $wiki_model->getPageInfoByPageId($page_id);
        $data['PAGE_NAME'] = htmlentities($page_info['PAGE_NAME'] ?? "");
        $page_info = $wiki_model->getPageInfoByName($group_id,
            $page_info['PAGE_NAME'] ?? "", $data['CURRENT_LOCALE_TAG'], 'edit');
        $data["PAGE"] = (is_array($page_info)) ? ($page_info["PAGE"] ?? "") :
            "";
        $this->checkAuthRequirement($data,
            $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID);
        if (!$data["AUTHORIZED"]) {
            $parent->web_site->header("HTTP/1.0 403 FORBIDDEN");
            $data["MEDIA_NAME"] = $media_name;
            $parent->displayView("nocache", $data);
            \seekquarry\atto\webExit(); //bail
        }
        $data['HEAD'] = WikiParser::parsePageHeadVars($page_info['PAGE'] ?? "");
        $resources_info = $wiki_model->getGroupPageResourceUrls(
            $group_id, $page_id, $sub_path);
        $data['ORIGINAL_URL_PREFIX'] = (is_array($resources_info)) ?
            ($resources_info['url_prefix'] ?? "") : "";
        $resources = $resources_info['resources'] ?? "";
        $num_resources = (is_array($resources)) ? count($resources) : 0;
        for ($i = 0; $i < $num_resources; $i++) {
            if ($resources[$i]['name'] == $media_name) {
                break;
            }
        }
        if ($i == $num_resources) {
            $parent->web_site->header("HTTP/1.0 404 Not Found");
            $data["MEDIA_NAME"] = $media_name;
            $parent->displayView("nocache", $data);
            \seekquarry\atto\webExit(); //bail
        }
        $data["RESOURCE_INFO"] = $resources[$i];
        $thumb_folder = $resources_info['thumb_folder'] ?? "";
        $description_file = $thumb_folder ."/$media_name.txt";
        $data['RESOURCE_DESCRIPTION'] = $wiki_model->getResourceDescription(
                $media_name, $group_id, $page_id, $sub_path);
        $data['RESOURCE_DESCRIPTION'] = (empty($data['RESOURCE_DESCRIPTION'])) ?
            tl('social_component_media_no_description') :
            $data['RESOURCE_DESCRIPTION'];
        $base_url = htmlentities(B\wikiUrl($data['PAGE_NAME'] , true,
            $data['CONTROLLER'], $group_id));
        if (isset($_SESSION['USER_ID']) && intval($_SESSION['USER_ID']) > 0) {
            $user_id = $_SESSION['USER_ID'];
            $data['ADMIN'] = 1;
        } else {
            $user_id = C\PUBLIC_USER_ID;
        }
        $csrf_token = $parent->generateCSRFToken(
            $user_id);
        if (!empty($data['ADMIN'])) {
            $base_url .= C\p('CSRF_TOKEN') . "=". $csrf_token;
        }
        $folder_prefix = $base_url . "&amp;";
        $folder_prefix .= "page_id=". $page_id;
        $data['ROOT_LINK'] = $folder_prefix;
        if (!empty($data['SUB_PATH'])) {
             $folder_prefix .= "&amp;sf=" . urlencode($data['SUB_PATH']);
        }
        $data['FOLDER_PREFIX'] = $folder_prefix;
        $url_prefix = $folder_prefix . "&amp;arg=media";
        $data['MEDIA_NAME'] = $media_name;
        $page_string = "";
        $data['URL_PREFIX'] = $url_prefix;
        $data['THUMB_PREFIX'] = $resources_info['thumb_prefix'];
        $data['ATHUMB_PREFIX'] = $resources_info['athumb_prefix'];
        $data['DEFAULT_THUMB_URL'] = C\SHORT_BASE_URL .
            $resources_info['default_thumb'];
        $data['DEFAULT_EDITABLE_THUMB_URL'] = C\SHORT_BASE_URL .
            $resources_info['default_editable_thumb'];
        $data['DEFAULT_FOLDER_THUMB_URL'] = C\SHORT_BASE_URL .
            $resources_info['default_folder_thumb'];
        $data['EDIT'] = ($_REQUEST['arg'] == "media-detail-edit") ?
            true : false;
        $data["MODE"] = "media-detail";
        $data['VIEW'] = "mediadetail";
    }
    /**
     * mediaWiki used to set up the partially processed wiki page, before media
     * inserted, needed to display a single media item on a media list. The name
     * of the media item to be display is expected to come from $_REQUEST['n'].
     * @param array &$data array of field variables for view will be modified by
     *     this function
     * @param int $group_id id of group wiki page belongs to
     * @param int $page_id id of wiki page
     * @param string $sub_path sub-resource folder that is being used, if any,
     *     to get resources from
     */
    public function mediaWiki(&$data, $group_id, $page_id, $sub_path="")
    {
        if (!isset($page_id) || !isset($_REQUEST['n'])) {
            return;
        }
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $media_name = $parent->clean($_REQUEST['n'], "file_name");
        $page_info = $wiki_model->getPageInfoByPageId($page_id);
        $data["DISCUSS_THREAD"] = $page_info["DISCUSS_THREAD"] ?? "";
        $data['SCRIPT'] = $data['SCRIPT'] ?? "";
        $data['PAGE_NAME'] = htmlentities($page_info['PAGE_NAME'] ?? "");
        $page_info = $wiki_model->getPageInfoByName($group_id,
            $page_info['PAGE_NAME'] ?? "", $data['CURRENT_LOCALE_TAG'], 'edit');
        $data["PAGE"] = (is_array($page_info)) ? ($page_info["PAGE"] ?? "") :
            "";
        $this->checkAuthRequirement($data,
            $_SESSION["USER_ID"] ?? C\PUBLIC_USER_ID);
        if (!$data["AUTHORIZED"]) {
            $parent->web_site->header("HTTP/1.0 403 FORBIDDEN");
            $data["MEDIA_NAME"] = $media_name;
            $parent->displayView("nocache", $data);
            \seekquarry\atto\webExit(); //bail
        }
        $data['RESOURCES_INFO'] = $wiki_model->getGroupPageResourceUrls(
            $group_id, $page_id, $sub_path);
        $data['HEAD'] = WikiParser::parsePageHeadVars($page_info['PAGE'] ?? "");
        $public_view_source = (!empty($data['HEAD']['public_source']) &&
            $data['HEAD']['public_source'] == 'true') ||
            empty($data['HEAD']['public_source']);
        $this->initUserResourcePreferences($data);
        $resources = $data['RESOURCES_INFO']['resources'] ?? "";
        $num_resources = (is_array($resources)) ? count($resources) : 0;
        for ($i = 0; $i < $num_resources; $i++) {
            if ($resources[$i]['name'] == $media_name) {
                break;
            }
        }
        if ($i == $num_resources) {
            $parent->web_site->header("HTTP/1.0 404 Not Found");
            $data["MEDIA_NAME"] = $media_name;
            $parent->displayView("nocache", $data);
            \seekquarry\atto\webExit(); //bail
        }
        $current_resource = $resources[$i];
        $is_static = ($data['CONTROLLER'] == 'static') ? true : false;
        $base_url = htmlentities(B\wikiUrl($data['PAGE_NAME'] , true,
            $data['CONTROLLER'], $group_id));
        if (isset($_SESSION['USER_ID']) && intval($_SESSION['USER_ID']) > 0) {
            $user_id = $_SESSION['USER_ID'];
            $data['ADMIN'] = 1;
        } else {
            $user_id = C\PUBLIC_USER_ID;
            if (!$public_view_source) {
                $parent->web_site->header("HTTP/1.0 404 Not Found");
                $data["MEDIA_NAME"] = $media_name;
                $parent->displayView("nocache", $data);
                \seekquarry\atto\webExit(); //bail
            }
        }
        $csrf_token = $parent->generateCSRFToken(
            $user_id);
        if (!empty($data['ADMIN'])) {
            $base_url .= C\p('CSRF_TOKEN') . "=". $csrf_token;
        }
        $folder_prefix = ($is_static) ? $base_url : $base_url . "&amp;";
        $folder_prefix .= "page_id=". $page_id;
        $data['ROOT_LINK'] = $folder_prefix;
        if (!empty($data['SUB_PATH'])) {
             $folder_prefix .= "&amp;sf=" . urlencode($data['SUB_PATH']);
        }
        $url_prefix = $folder_prefix . "&amp;arg=media";
        $mime_type = L\mimeType($media_name);
        $prev_name = ($i < $num_resources &&
            isset($resources[$i - 1]['name'])) ?
            $resources[$i - 1]['name'] : false;
        $next_name = (isset($resources[$i + 1]['name'])) ?
            $resources[$i + 1]['name'] : false;
        $name_parts = pathinfo($media_name);
        $file_name = $name_parts['filename'];
        $data['MEDIA_NAME'] = $media_name;
        $page_string = ($is_static) ? "" : WikiParser::makeWikiPageHead(
                ["page_type" => "media_item"]) . WikiParser::END_HEAD_VARS;
        $data['URL_PREFIX'] = $url_prefix;
        if (!empty($prev_name)) {
            $data['PREV_LINK'] = "$url_prefix&amp;n=" . urlencode($prev_name);
            $prev_link = $data['PREV_LINK'];
            if (!in_array($mime_type, ["application/epub+zip",
                "application/pdf", 'video/mp4', 'video/m4v'])) {
                $data['SCRIPT'] .= 'leftSwipe(document, function(evt) {'.
                    'window.location="'.$prev_link.'";})'."\n";
            }
        }
        if (!empty($next_name)) {
            $data['NEXT_LINK'] = "$url_prefix&amp;n=" . urlencode($next_name);
            $data['NEXT_INDEX'] = $i+1;
            $next_link = $data['NEXT_LINK'];
            if (!in_array($mime_type, ["application/epub+zip",
                "application/pdf", 'video/mp4', 'video/m4v'])) {
                $data['SCRIPT'] .= 'rightSwipe(document, function(evt) {'.
                    'window.location="'.$next_link.'";})'."\n";
            }
        }
        /* The way to fetch this file down, which every file being read
           is given beside the chooser of which one it is, whatever kind
           of file it is. Worked out before the branches below, each of
           which is about one kind. */
        $data['RESOURCE_DOWNLOAD_URL'] =
            $wiki_model->getGroupPageResourceUrl($csrf_token, $group_id,
            $page_id, $media_name, $data['SUB_PATH'] ?? "");
        $data['RESOURCE_DOWNLOAD_URL'] .=
            (strpos($data['RESOURCE_DOWNLOAD_URL'], "?") === false) ?
            "?download=true" : "&amp;download=true";
        if (in_array($mime_type, ['video/mp4', 'video/m4v'])) {
            $current_url = $_SERVER['REQUEST_URI'];
            $current_url = substr($current_url, strlen(C\SHORT_BASE_URL));
            $current_url = C\baseUrl() . $current_url;
            $current_url = preg_replace("/". C\p('CSRF_TOKEN') .
                "\=[^\/\&]+(\/|\&)/", "", $current_url);
            $resource_url = $wiki_model->getGroupPageResourceUrl($csrf_token,
                $group_id, $page_id, $media_name, $data['SUB_PATH'] ?? "");
            $resource_url = substr($resource_url, strlen(C\SHORT_BASE_URL));
            $resource_url = C\baseUrl() . $resource_url;
            $resource_url = preg_replace("/". C\p('CSRF_TOKEN') .
                "\=[^\/\&]+/", "-", $resource_url);
            $thumb_url = "";
            if (C\REDIRECTS_ON) {
                if (!empty($current_resource['has_animated_thumb'])) {
                    $thumb_url = str_replace("wd/resources", "wd/athumbs",
                        $resource_url);
                } else if (!empty($current_resource['has_thumb'])) {
                    $thumb_url = str_replace("wd/resources", "wd/thumbs",
                        $resource_url);
                }
            } else {
                if (!empty($current_resource['has_animated_thumb'])) {
                    $thumb_url = $resource_url . "&amp;t=" .
                        C\MOVING_THUMB_ARG;
                } else if (!empty($current_resource['has_thumb'])) {
                    $thumb_url = $resource_url . "&amp;t=thumbs";
                }
            }
            list($folder, $thumb_folder) =
                $wiki_model->getGroupPageResourcesFolders(
                $group_id, $page_id, $data['SUB_PATH'] ?? "");
            $media_path = "$folder/$media_name";
            $pub_date = filemtime($media_path);
            $additional_metas =
                "\n<meta property='og:type' content='video' >\n" .
                "<meta property='og:url' content='$current_url' >\n" .
                "<meta property='og:title' content='$media_name' >\n" .
                "<meta property='video:release_date' content='" .
                    date("c", $pub_date) . "' >\n";
            if (!empty($thumb_url)) {
                $additional_metas .=
                    "<meta property='og:image' content='$thumb_url' >\n";
            }
            if (C\nsdefined("SITE_NAME")) {
                $additional_metas .=
                    "<meta property='og:site_name' content='". C\SITE_NAME .
                    "' >\n";
            }
            $probe = $this->videoProbeInfo($media_path, $thumb_folder,
                $media_name);
            if (!empty($probe)) {
                $width = $probe['width'];
                $height = $probe['height'];
                $duration = $probe['duration'];
                $additional_metas .=
                    "<meta property='og:video:width' content='$width' >\n".
                    "<meta property='og:video:height' ".
                    "content='$height' >\n" .
                    "<meta property='video:duration' " .
                    "content='$duration' >";
            }
            $description = $wiki_model->getResourceDescription(
                    $media_name, $group_id, $page_id, $data['SUB_PATH'] ?? "");
            if (!empty($description)) {
                $description = htmlentities(strip_tags($description));
                $additional_metas .= "\n<meta property='og:description' " .
                    "content='$description' >\n";
            }
            if (!empty($data['VIEW'])) {
                $view = $parent->view($data['VIEW']);
                $view->head_objects['additional_metas'] = $additional_metas;
            }
        }
        /* a single csv shown as its own page is the explicit-ask case for
           the download link and histogram toggle, so it requests them with
           the !verbose flag */
        $verbose_marker = ($mime_type == 'text/csv') ? "!verbose" : "";
        $page_string .= "<div class='media-container'>";
        if (!empty($sub_path)) {
            $page_string .= "((resource:$media_name$verbose_marker".
                "|$sub_path|$file_name ))";
        } else {
            $page_string .= "((resource:$media_name$verbose_marker".
                "|$file_name ))";
        }
        $page_string .= "</div>";
        $include_charts_and_spreadsheets = ($mime_type == 'text/csv') ?
            true : false;
        $data["PAGE"] = $wiki_model->insertResourcesParsePage(
            $group_id, $page_id, $data['CURRENT_LOCALE_TAG'],
            $page_string, $csrf_token, $data['CONTROLLER'],
            $include_charts_and_spreadsheets);
        if (str_starts_with($mime_type, 'text')) {
            $parent->recordViewSession($page_id, $sub_path, $media_name);
        }
        if ($mime_type == "text/csv" && empty($data['RAW'])) {
            $data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
            $data['SPREADSHEET'] = true;
        }
        $data["PAGE_ID"] = $page_id;
        if (!empty($data['RESOURCES_INFO']['thumb_folder'])) {
            $resource_id = unpack('n', md5($group_id . $page_id .
                $data['RESOURCES_INFO']['thumb_folder'] . "/" .
                $_REQUEST['n'], true))[1];
            $parent->model("impression")->add($user_id, $resource_id,
                C\RESOURCE_IMPRESSION);
        }
    }
    /**
     * canCreateGitRepository decides whether a user is allowed to turn a wiki
     * page into a git repository. This is allowed unless every one of the
     * user's roles that carries the Feed and Wiki activity also carries the
     * no_git_repository modifier, which removes the ability. The root account
     * may always do it.
     * @param int $user_id id of the user whose roles are checked
     * @return bool whether the user may create a git repository page
     */
    protected function canCreateGitRepository($user_id)
    {
        if ($user_id == C\ROOT_ID) {
            return true;
        }
        $user_model = $this->parent->model("user");
        $activities = $user_model->getUserActivities($user_id);
        foreach ($activities as $activity) {
            if ($activity["METHOD_NAME"] == "groupFeeds") {
                $modifiers = preg_split("/\s*,\s*/",
                    trim((string)($activity["ALLOWED_ARGUMENTS"] ?? "")));
                if (!in_array("no_git_repository", $modifiers)) {
                    return true;
                }
            }
        }
        return false;
    }
    /**
     * initializeGitAppCode prepares the Git application-code controls a person
     * sees when they open a Git repository wiki page for editing. This code
     * stands in for the person's account password when they push to the
     * repository, so the clone address shown here has their name and code woven
     * in. The same saved code is shown on each visit; the person can ask for a
     * fresh one by giving their password and choosing how long it should last.
     * A fresh code is made from that password, the site's secret key, and the
     * current time, and is saved for next time. An expired code is reported
     * rather than shown.
     * @param array &$data view data to fill with the sign-in clone address, the
     *     expiry choices, and any message about a refused or expired code
     * @param string $clone_url the plain clone address for this repository, the
     *     address the person's name and code are woven into
     */
    protected function initializeGitAppCode(&$data, $clone_url)
    {
        $parent = $this->parent;
        $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
        $username = $_SESSION['USER_NAME'] ?? "";
        $git_model = $parent->model("git");
        $durations = ["month" => C\ONE_MONTH,
            "three_months" => 3 * C\ONE_MONTH,
            "six_months" => 6 * C\ONE_MONTH, "year" => C\ONE_YEAR,
            "never" => C\FOREVER];
        $data['GIT_APP_CODE_DURATIONS'] = array_keys($durations);
        $wants_refresh = !empty($_REQUEST['git_app_refresh']);
        if ($wants_refresh) {
            $signin_model = $parent->model("signin");
            $check_name = $username;
            $password = $_REQUEST['git_app_password'] ?? "";
            if ($signin_model->checkValidSignin($check_name, $password)) {
                $chosen = $_REQUEST['git_app_expiry'] ?? "";
                $duration = $durations[$chosen] ?? C\ONE_MONTH;
                $expires = ($duration == C\FOREVER) ? C\FOREVER :
                    time() + $duration;
                $app_code = L\crawlAuthHash($password . microtime());
                $git_model->setAppCode($user_id, $app_code, $expires);
            } else {
                $data['GIT_APP_CODE_BAD_PASSWORD'] = true;
            }
        }
        $record = $git_model->getAppCode($user_id);
        $valid = !empty($record['APP_CODE']) &&
            ($record['EXPIRES'] == C\FOREVER ||
            $record['EXPIRES'] > time());
        if ($valid) {
            $scheme_end = strpos($clone_url, "://");
            if ($scheme_end === false) {
                $data['GIT_AUTH_CLONE_URL'] = $clone_url;
            } else {
                $data['GIT_AUTH_CLONE_URL'] =
                    substr($clone_url, 0, $scheme_end + 3) .
                    rawurlencode($username) . ":" . $record['APP_CODE'] .
                    "@" . substr($clone_url, $scheme_end + 3);
            }
            $data['GIT_APP_CODE_EXPIRES'] = $record['EXPIRES'];
        } else if (!empty($record['APP_CODE'])) {
            $data['GIT_APP_CODE_EXPIRED'] = true;
        }
    }
    /**
     * initializeGitStatistics works out, and remembers, the statistics shown
     * for a Git repository wiki page: how many commits and files it has, its
     * busiest authors, its commits by month, and its commonest file endings.
     * The numbers are worked out from the repository once for a given newest
     * commit and saved, so later views reuse them until a new commit changes
     * the tip of the branch. Everything is shaped into bar rows the view can
     * draw with no further arithmetic. The page's bare repository is opened
     * here and its newest commit found, so this can be called wherever the page
     * id is known without the read view having to hand a repository across.
     * @param array &$data view data to fill with the statistics
     * @param int $group_id id of the group the page belongs to, used to find
     *     the repository and name the saved-statistics file
     * @param int $page_id id of the page, used to find the repository and name
     *     the saved-statistics file
     */
    protected function initializeGitStatistics(&$data, $group_id, $page_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $git_model = $parent->model("git");
        $folders = $wiki_model->getGroupPageResourcesFolders($group_id,
            $page_id, "", true, true);
        $repository = new LVC\GitRepository($folders[0]);
        if (!$repository->isRepository()) {
            return;
        }
        $branches = $repository->branches();
        if (empty($branches)) {
            return;
        }
        $branch = $repository->headBranch();
        if (!isset($branches[$branch])) {
            $branch = array_key_first($branches);
        }
        $head_commit = $branches[$branch];
        $cache_path = C\WORK_DIRECTORY . "/cache/git-stats-" .
            $group_id . "-" . $page_id . ".json";
        $stats = $git_model->readStatisticsCache($cache_path, $head_commit);
        if ($stats === false) {
            $stats = $repository->statistics($head_commit);
            $git_model->writeStatisticsCache($cache_path, $head_commit,
                $stats);
        }
        $data["GIT_STATS_COMMITS"] = $stats["commits"];
        $data["GIT_STATS_FILES"] = $stats["files"];
        /* the full, sorted rows are passed for each group; the view shows a
           top handful and offers the rest behind a "more" control, so a
           repository with many authors, file types, or months of history
           can be explored without a long wall of bars by default */
        $authors = $stats["authors"];
        arsort($authors);
        $data["GIT_STATS_AUTHORS"] = $this->gitStatsBars($parent, $authors);
        $extensions = $stats["extensions"];
        arsort($extensions);
        $data["GIT_STATS_TYPES"] = $this->gitStatsBars($parent, $extensions);
        $months = $stats["months"];
        ksort($months);
        $data["GIT_STATS_MONTHS"] = $this->gitStatsBars($parent,
            array_reverse($months, true));
    }
    /**
     * initializeGitRepositoryReadMode prepares everything the read view needs
     * to show a Git repository wiki page as a browsable file tree. A Git
     * repository page keeps its files inside a bare repository in the page's
     * resource folder rather than as wiki text, so this opens that repository,
     * picks the branch to show, walks to the folder or file named in the
     * address, and hands the view an escaped listing, an optional file's
     * contents, a rendered README, the branch list, breadcrumb links, and the
     * address a visitor would clone from. When the repository holds no commits
     * yet the page is marked empty so the view can say so instead of listing
     * nothing.
     * @param array &$data view data array; on return carries the GIT_ fields
     *     the read view renders
     * @param int $group_id id of the group the page belongs to
     * @param string $sub_path folder path from the wiki resource system, unused
     *     here because the path inside the repository travels in its own
     *     request field
     */
    public function initializeGitRepositoryReadMode(&$data, $group_id,
        $sub_path)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $page_name = $data["PAGE_NAME"];
        $data["GIT_REPO"] = true;
        /*
           Everything a visitor can steer in this request is read here, in
           one place, so a reader can see the whole request surface at a
           glance. Each of these is checked further down against the
           repository's own data (its branch and tag names, its object
           names) or against a fixed set of choices before it is acted on,
           which is why they travel from here as the raw text the visitor
           sent. Where any of them is shown back to the visitor it is
           escaped at the point it is placed into the view data, not here.
        */
        $requested_branch = $_REQUEST["repo_branch"] ?? "";
        $requested_ref = $_REQUEST["repo_ref"] ?? "";
        $requested_commit = $_REQUEST["repo_commit"] ?? "";
        $requested_download = $_REQUEST["repo_download"] ?? "";
        $requested_path = $_REQUEST["repo_path"] ?? "";
        $requested_view = $_REQUEST["repo_view"] ?? "";
        $data["GIT_CLONE_URL"] = C\p('NAME_SERVER') . "group/" . $group_id .
            "/" . $page_name . ".git";
        $folders = $wiki_model->getGroupPageResourcesFolders($group_id,
            $data["PAGE_ID"], "", true, true);
        $repository = new LVC\GitRepository($folders[0]);
        if ($repository->folderState() === "occupied") {
            /* the resource path points at a folder that already holds other
               content and is not a repository, so a git request cannot be
               served from it; flag this so the read view can warn */
            $data["RESOURCE_PATH_NOT_GIT"] = true;
        }
        $branches = $repository->isRepository() ?
            $repository->branches() : [];
        $data["GIT_BRANCHES"] = array_keys($branches);
        if (empty($branches)) {
            /* the repository has no commits yet, but its issue tracker does
               not depend on repository content and should still open, so
               people can report and discuss issues before any code has been
               pushed; every other view has nothing to show and falls back to
               the empty-repository notice */
            if ($requested_view === "issues") {
                $prefix = B\wikiUrl($page_name, true, $data["CONTROLLER"],
                    $group_id);
                $this->initializeGitIssues($data, $group_id, $page_name,
                    $prefix, $repository, "", $branches);
                return;
            }
            $data["GIT_EMPTY"] = true;
            return;
        }
        $branch = $repository->headBranch();
        if ($requested_branch !== "" &&
            isset($branches[$requested_branch])) {
            $branch = $requested_branch;
        }
        if (!isset($branches[$branch])) {
            $branch = array_key_first($branches);
        }
        $data["GIT_BRANCH"] = $parent->clean($branch, "string");
        $head_commit = $branches[$branch];
        $view_commit = $head_commit;
        $ref_label = tl("wiki_element_git_head");
        $ref_query = "";
        if ($requested_ref !== "") {
            foreach ($repository->tags() as $tag) {
                if ($tag["name"] === $requested_ref) {
                    $view_commit = $tag["sha"];
                    $ref_label = $parent->clean($tag["name"], "string");
                    $ref_query =
                        "&repo_ref=" . rawurlencode($tag["name"]);
                    break;
                }
            }
        } else if ($requested_commit !== "" &&
            ctype_xdigit($requested_commit) &&
            strlen($requested_commit) ===
            LVC\GitRepository::SHA_HEX_LENGTH) {
            try {
                $repository->commit($requested_commit);
                $view_commit = $requested_commit;
                $ref_label = substr($view_commit, 0,
                    C\GIT_SHORT_HASH_LENGTH);
                $ref_query = "&repo_commit=" . $view_commit;
            } catch (\Exception $error) {
                $view_commit = $head_commit;
            }
        }
        $data["GIT_REF_LABEL"] = $ref_label;
        if ($requested_download !== "" && $requested_path === "") {
            $this->gitStreamArchive($repository, $view_commit, $page_name,
                $requested_download);
            return;
        }
        $commit = $repository->commit($view_commit);
        $path_parts = array_values(array_filter(explode("/", $requested_path),
            function ($part) {
                return $part !== "" && $part !== "." && $part !== "..";
            }));
        $tree_sha = $commit["tree"];
        $walked = [];
        $file_entry = null;
        foreach ($path_parts as $part) {
            $found = null;
            foreach ($repository->tree($tree_sha) as $entry) {
                if ($entry["name"] === $part) {
                    $found = $entry;
                    break;
                }
            }
            if ($found === null) {
                break;
            }
            $walked[] = $part;
            if ($found["is_dir"]) {
                $tree_sha = $found["sha"];
            } else {
                $file_entry = $found;
                break;
            }
        }
        $current_path = implode("/", $walked);
        $data["GIT_PATH"] = $parent->clean($current_path, "string");
        if ($requested_download !== "") {
            $this->gitStreamResource($repository, $file_entry, $tree_sha,
                $view_commit, $page_name, $current_path,
                $requested_download);
            return;
        }
        $prefix = B\wikiUrl($page_name, true, $data["CONTROLLER"], $group_id);
        if (!empty($data[C\p('CSRF_TOKEN')])) {
            $prefix .= C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] . "&";
        }
        $link_for = function ($path) use ($prefix, $branch, $ref_query) {
            return htmlentities($prefix . "arg=read&repo_branch=" .
                rawurlencode($branch) . $ref_query . "&repo_path=" .
                rawurlencode($path));
        };
        $data["GIT_BRANCH_OPTIONS"] = [];
        foreach ($branches as $branch_name => $branch_sha) {
            $data["GIT_BRANCH_OPTIONS"][] = ["NAME" =>
                $parent->clean($branch_name, "string"), "SELECTED" =>
                ($branch_name === $branch), "URL" => htmlentities($prefix .
                "arg=read&repo_branch=" . rawurlencode($branch_name) .
                "&repo_path=")];
        }
        $data["GIT_REF_MENU"] = $this->gitRefMenu($repository, $prefix,
            $branch, $view_commit);
        $download_base = $prefix . "arg=read&repo_branch=" .
            rawurlencode($branch) . $ref_query;
        $data["GIT_DOWNLOAD_TARGZ"] =
            htmlentities($download_base . "&repo_download=targz");
        $data["GIT_DOWNLOAD_ZIP"] =
            htmlentities($download_base . "&repo_download=zip");
        $resource_download = ($file_entry !== null) ? "raw" : "targz";
        $data["GIT_RESOURCE_DOWNLOAD_URL"] = htmlentities($download_base .
            "&repo_path=" . rawurlencode($current_path) .
            "&repo_download=" . $resource_download);
        $crumbs = [["NAME" => $parent->clean($page_name, "string"),
            "URL" => $link_for("")]];
        $trail = "";
        foreach ($walked as $part) {
            $trail = ($trail === "") ? $part : $trail . "/" . $part;
            $crumbs[] = ["NAME" => $parent->clean($part, "string"),
                "URL" => $link_for($trail)];
        }
        $data["GIT_CRUMBS"] = $crumbs;
        $data["GIT_PARENT_URL"] = (count($crumbs) > 1) ?
            $crumbs[count($crumbs) - 2]["URL"] : "";
        if ($requested_view === "commits") {
            $this->gitListView($data, $repository, $view_commit, $prefix,
                $branch, $ref_query, "commits");
            return;
        }
        if ($requested_view === "tags") {
            $this->gitListView($data, $repository, $view_commit, $prefix,
                $branch, $ref_query, "tags");
            return;
        }
        if ($requested_view === "diff") {
            $this->gitCommitDiff($data, $repository, $view_commit, $prefix,
                $branch, $ref_query);
            return;
        }
        if ($requested_view === "issues") {
            $this->initializeGitIssues($data, $group_id, $page_name,
                $prefix, $repository, $branch, $branches);
            return;
        }
        if ($file_entry !== null) {
            $blob = $repository->blob($file_entry["sha"]);
            if (strlen($blob) > C\MAX_GIT_BLOB_VIEW_LEN) {
                $data["GIT_FILE_TOO_LARGE"] = true;
            } else {
                $media_type = $this->gitRenderableMediaType(
                    $file_entry["name"]);
                if ($media_type !== "") {
                    $data["GIT_FILE_MEDIA"] = "data:" . $media_type .
                        ";base64," . base64_encode($blob);
                    $kind = "image";
                    if ($media_type === "application/pdf") {
                        $kind = "pdf";
                    } else if (str_starts_with($media_type, "audio/")) {
                        $kind = "audio";
                    } else if (str_starts_with($media_type, "video/")) {
                        $kind = "video";
                    }
                    $data["GIT_FILE_MEDIA_KIND"] = $kind;
                } else if (str_contains($blob, "\0")) {
                    $data["GIT_FILE_BINARY"] = true;
                } else {
                    $data["GIT_FILE"] = $parent->clean($blob, "string");
                }
            }
            return;
        }
        $entries = $repository->tree($tree_sha);
        $listed_folders = [];
        $listed_files = [];
        $folder_names = [];
        $file_names = [];
        $readme_sha = "";
        foreach ($entries as $entry) {
            $entry_path = ($current_path === "") ? $entry["name"] :
                $current_path . "/" . $entry["name"];
            $item = ["NAME" => $parent->clean($entry["name"], "string"),
                "IS_DIR" => $entry["is_dir"], "URL" => $link_for($entry_path)];
            if ($entry["is_dir"]) {
                $listed_folders[] = $item;
                $folder_names[] = $entry["name"];
            } else {
                $listed_files[] = $item;
                $file_names[] = $entry["name"];
                if ($readme_sha === "" && in_array(
                    strtolower($entry["name"]), ["readme.md", "readme"])) {
                    $readme_sha = $entry["sha"];
                }
            }
        }
        $data["GIT_ENTRIES"] = array_merge($listed_folders, $listed_files);
        $ordered_names = array_merge($folder_names, $file_names);
        $commit_info = [];
        try {
            $commit_info = $repository->lastCommitForEntries(
                $view_commit, $walked, $ordered_names);
        } catch (\Exception $error) {
            $commit_info = [];
        }
        foreach ($ordered_names as $index => $raw_name) {
            $summary = "";
            $commit_time = 0;
            $commit_author = "";
            if (isset($commit_info[$raw_name])) {
                $summary = $parent->clean(
                    $commit_info[$raw_name]["summary"], "string");
                $commit_time = (int)$commit_info[$raw_name]["time"];
                $commit_author = $parent->clean(
                    $commit_info[$raw_name]["author"] ?? "", "string");
            }
            $data["GIT_ENTRIES"][$index]["COMMIT_SUMMARY"] = $summary;
            $data["GIT_ENTRIES"][$index]["COMMIT_TIME"] = $commit_time;
            $data["GIT_ENTRIES"][$index]["COMMIT_AUTHOR"] = $commit_author;
        }
        if ($readme_sha !== "") {
            $readme = $repository->blob($readme_sha);
            if (strlen($readme) <= C\MAX_GIT_BLOB_VIEW_LEN &&
                !str_contains($readme, "\0")) {
                $wiki_parser = new LW\WikiParser();
                $data["GIT_README_HTML"] =
                    $wiki_parser->parseMarkdown($readme);
                $data["GIT_README_TOC"] =
                    $this->gitReadmeToc($data["GIT_README_HTML"]);
            }
        }
    }

    /**
     * CATEGORY_LIST_LENGTH how many pages a category list on a front page shows
     * before it stops, so a busy category does not push the rest of the page
     * off the bottom of the screen
     * @var int
     */
    const CATEGORY_LIST_LENGTH = 10;
    /**
     * LEAD_PARAGRAPHS how many paragraphs of a story a front page shows before
     * offering the rest of it
     * @var int
     */
    const LEAD_PARAGRAPHS = 2;
    /**
     * BANNER_NAME what a page's banner is called among its resources, before
     * the ending that says what kind of picture it is
     * @var string
     */
    const BANNER_NAME = "banner";
    /**
     * LEAD_LETTERS how many letters of a story a front page shows where the
     * story has no paragraphs of its own to take
     * @var int
     */
    const LEAD_LETTERS = 400;
    /**
     * CLOSES_ITSELF the elements that never take a closing tag of their own, so
     * nothing is left standing for them when a page is cut short
     * @var array
     */
    const CLOSES_ITSELF = ["area", "base", "br", "col", "embed", "hr",
        "img", "input", "link", "meta", "param", "source", "track",
        "wbr"];

    const RESOURCE_ARCHIVE_FOLDER = ".archive";
    /**
     * PAGE_RESOURCES_ZIP_FOLDER what a download of a page's resources is called
     * when the page turns out to have no usable name to call it after.
     * Everything in the archive sits under one folder of this name, so
     * unpacking it leaves one folder behind rather than scattering a page's
     * resources into whatever folder it was unpacked in.
     */
    const PAGE_RESOURCES_ZIP_FOLDER = "page_resources";
    /**
     * STATIC_FOLDER_INDEX_FILE file that stands for a folder in a static HTML
     * folder when the page names none of its own, matching what a web server
     * calls a directory index.
     */
    const STATIC_FOLDER_INDEX_FILE = "index.html";
    /**
     * STATIC_FOLDER_MISSING_BODY what is sent as the body when a static HTML
     * folder holds nothing by the name asked for. It is plain text because
     * whatever asked may have been a stylesheet or a script rather than a
     * reader.
     */
    const STATIC_FOLDER_MISSING_BODY = "Not Found";
    /**
     * UPLOAD_NO_FILES constant for when attempt to handle file uploads and no
     * files were uploaded
     */
    const UPLOAD_NO_FILES = -1;
    /**
     * UPLOAD_FAILED constant for when attempt to handle file uploads and not
     * all of the file upload information was present
     */
    const UPLOAD_FAILED = 0;
    /**
     * UPLOAD_SUCCESS constant for when attempt to handle file uploads and file
     * were successfully uploaded
     */
    const UPLOAD_SUCCESS = 1;
    /**
     * RECOMMENDATION_FILE file to tell RecommendationJob the paths of eligible
     * wiki resources description files
     */
    const RECOMMENDATION_FILE = C\APP_DIR . "/resources/recommendation.txt";
    /**
     * NOTHING_PICKED the value a list of choices carries while nobody has
     * picked anything from it. A page writes such an opening line itself, as a
     * row of dashes, and the site puts this in its place so a form coming back
     * can tell an untouched list from a chosen answer.
     * @var string
     */
    const NOTHING_PICKED = "choice";

    /**
     * editWikiHeadVars reads the settings a writer chose for a page
     * and gives them back as the values that go at the head of it.
     *
     * editWikiPageSettings calls this before it saves. Each setting
     * keeps the value the page already carried unless the request
     * names it, and a value the request names is checked against what
     * that setting may hold, so a form sending anything else leaves
     * the page as it was. Naming a folder of files to serve, or a
     * folder of resources to draw from, is settled here too, since
     * both are refused where the folder is not there.
     *
     * @param array &$data what the page will show, added to here
     * @param array $head_object the values the page already carries
     * @param array $page_info what the model holds about the page
     * @param string $page what the writer typed
     * @param string $sub_path folder under the page the writer is in
     * @return array the settings first, then whether the head has to
     *     be written, then whether a folder was named, then whether
     *     the folder named is not there
     */
    protected function editWikiHeadVars(&$data, $head_object, $page_info,
        $page, $sub_path)
    {
        $parent = $this->parent;
        $write_head = false;
        $head_vars = [];
        $page_types = array_keys($data['page_types']);
        $page_borders = array_keys($data['page_borders']);
        $set_path = false;
        $resource_path_error = false;
        foreach (WikiParser::PAGE_DEFAULTS as $key => $default) {
            $head_vars[$key] = (isset($head_object[$key])) ?
                $head_object[$key] : $default;
            if (isset($_REQUEST[$key])) {
                $head_vars[$key] =  trim(
                    $parent->clean($_REQUEST[$key], "string"));
                switch ($key) {
                    case 'anonymous_issue_reporting':
                        if (!in_array($head_vars[$key],
                            [C\GIT_ISSUE_ANON_NONE,
                            C\GIT_ISSUE_ANON_MODERATED,
                            C\GIT_ISSUE_ANON_UNMODERATED])) {
                            $head_vars[$key] = $default;
                        }
                        break;
                    case 'page_type':
                        if (!in_array($head_vars[$key], $page_types) &&
                            ($head_vars[$key][0] != 't' ||
                            !is_numeric(substr($head_vars[$key],1))) ) {
                            $head_vars[$key] =
                                $head_object[$key] ?? $default;
                        }
                        break;
                    case 'page_borders':
                        if (!in_array($head_vars[$key],
                            $page_borders)) {
                            $head_vars[$key] = $default;
                        }
                        break;
                    case 'alternative_path':
                        if (!empty($head_vars[$key]) &&
                            !is_dir($head_vars[$key])) {
                            $parent_folder =
                                dirname($head_vars[$key]);
                            if (!is_dir($parent_folder) ||
                                !@mkdir($head_vars[$key])) {
                                $resource_path_error = true;
                                $head_vars[$key] = $default;
                            }
                        }
                        if ((empty($head_vars[$key]) ||
                            is_dir($head_vars[$key])) &&
                            !empty($_SESSION['USER_ID']) &&
                            $_SESSION['USER_ID'] == C\ROOT_ID) {
                            $set_path = true;
                        }
                        break;
                    case 'default_sort':
                        if (empty($page) &&
                            !isset($page_info['PAGE'])) {
                            break;
                        }
                        if (in_array($head_vars[$key],
                            ['name', 'size', 'modified'])) {
                            if (isset($page_info['PAGE'])) {
                                if (!isset($page)) {
                                    $page_parts = explode(
                                        WikiParser::END_HEAD_VARS,
                                        $page_info['PAGE']);
                                    $page = isset($page_parts[1]) ?
                                        $page_parts[1] : $page_parts[0];
                                }
                            }
                            $new_key = 'a' . $head_vars[$key];
                            if (isset($head_object['default_sort'])) {
                                set_error_handler(null);
                                $head_object['default_sort'] =
                                    @unserialize(L\webdecode(
                                    $head_object['default_sort']));
                                restore_error_handler();
                            } else {
                                $head_object['default_sort'] = [];
                            }
                            $sort_path = empty($sub_path) ? "." :
                                $sub_path;
                            $sort_path = rtrim($sort_path, '/');
                            $direction = '_asc';
                            if (empty($head_object['default_sort'][
                                $sort_path]) ||
                                $head_object['default_sort'][$sort_path]
                                == $new_key) {
                                $direction = '_desc';
                                $new_key = 'r' . $head_vars[$key];
                            }
                            $head_object['default_sort'][$sort_path] =
                                $new_key;
                            $folder_hash_id = L\crawlHash(
                                $page_info['ID'] . $sub_path);
                            $_SESSION['media_sorts'][$folder_hash_id] =
                                $head_vars[$key] . $direction;
                            $head_vars[$key] = L\webencode(serialize(
                                $head_object['default_sort']));
                            $edit_reason = "Change resource sort";
                            $write_head = true;
                        } else {
                            $head_vars[$key] = $default;
                        }
                        break;
                    case 'static_html_folder':
                    case 'directory_indexes':
                    case 'index_file':
                        if (empty($_SESSION['USER_ID']) ||
                            $_SESSION['USER_ID'] != C\ROOT_ID) {
                            $head_vars[$key] =
                                (isset($head_object[$key])) ?
                                $head_object[$key] : $default;
                        } else if ($key == 'index_file') {
                            $head_vars[$key] =
                                $this->cleanIndexFileSetting(
                                $head_vars[$key]);
                            if ($head_vars[$key] === '') {
                                $head_vars[$key] = $default;
                            }
                        }
                        break;
                    default:
                        $head_vars[$key] =
                            trim(preg_replace("/\n+/", "\n",
                            $head_vars[$key]));
                }
                if ($head_vars[$key] != $default) {
                    $write_head = true;
                }
            } else if ($key == 'toc' || $key == 'public_source') {
                if (isset($_REQUEST['title'])) {
                    $head_vars[$key] = false;
                } else {
                    $head_vars[$key] == true;
                }
            } else if ($key == 'static_html_folder' ||
                $key == 'directory_indexes') {
                if (!empty($_SESSION['USER_ID']) &&
                    $_SESSION['USER_ID'] == C\ROOT_ID &&
                    isset($_REQUEST['title'])) {
                    $head_vars[$key] = false;
                }
            }  else if ($key == 'update_description') {
                $head_vars[$key] =
                    isset($_REQUEST['update_description']);
            }
        }
        return [$head_vars, $write_head, $set_path,
            $resource_path_error];
    }
    /**
     * cleanWikiRequestFields reads the wiki fields a request carries
     * and gives them back cleaned, along with whether a field the
     * page cannot do without is absent.
     *
     * wiki calls this before it does anything else, since the group
     * and the page name worked out here decide every step after it. A
     * request may name a group by its number or by its name, and a
     * name is looked up here so that either reaches the same group. A
     * reader who is not signed in has these fields kept in the
     * session, which is what brings them back to the same page once
     * they sign in.
     *
     * @param array $clean_array name of each field to read and the
     *     kind of value it holds
     * @param array $strings_array longest each string field may be
     * @param string $controller_name name of the controller the
     *     request came to, kept for a reader who is not signed in
     * @param int $user_id which reader is asking
     * @return array the cleaned fields first, and whether a field the
     *     page cannot do without is absent second
     */
    public function cleanWikiRequestFields($clean_array,
        $strings_array, $controller_name, $user_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        /* The first two fields named are the ones the page cannot do
           without, so only those two are reported as absent. */
        $last_care_missing = 2;
        $missing_fields = false;
        $i = 0;
        if ($user_id == C\PUBLIC_USER_ID) {
            $_SESSION['LAST_ACTIVITY']['a'] = 'wiki';
            $_SESSION['LAST_ACTIVITY']['c'] = $controller_name;
        } else {
            unset($_SESSION['LAST_ACTIVITY']);
        }
        $fields = [];
        $missings = [];
        foreach ($clean_array as $field => $type) {
            if (isset($_REQUEST[$field])) {
                if ($field == 'page' && is_array($_REQUEST[$field])) {
                    $tmp = [];
                    foreach ($_REQUEST[$field] as $key => $value) {
                        $key = $parent->clean($key, "string");
                        $value = $parent->clean($value, "string");
                        $tmp[substr($key, 0, C\TITLE_LEN)] =
                            substr($value, 0, C\MAX_GROUP_PAGE_LEN);
                    }
                } else {
                    $tmp = $parent->clean($_REQUEST[$field], $type);
                }
                if (isset($strings_array[$field]) &&
                    !is_array($tmp)) {
                    $tmp = substr($tmp, 0, $strings_array[$field]);
                }
                if ($field == "page_name") {
                    $tmp = str_replace(" ", "_", $tmp);
                    $tmp = str_replace("$", "", $tmp);
                }
                if ($field == "group_name") {
                    $pre_id = $group_model->getGroupId($tmp);
                    if ($pre_id > 0) {
                        $fields['group_id'] = $pre_id;
                        /* What is reported absent is the group, under
                           the name group_id, so that is what a group
                           found by its name clears. Clearing it under
                           the name of the field being read cleared
                           nothing, since group_name is never among the
                           two that are reported. */
                        unset($missings['group_id']);
                        if (empty($missings)) {
                            $missing_fields = false;
                        }
                    }
                }
                $fields[$field] = $tmp;
                if ($user_id == C\PUBLIC_USER_ID) {
                    $_SESSION['LAST_ACTIVITY'][$field] = $tmp;
                }
            } else if ($i < $last_care_missing) {
                $fields[$field] = false;
                $missing_fields = true;
                $missings[$field] = true;
            }
            $i++;
        }
        return [$fields, $missing_fields];
    }

    /**
     * feedPostFields gives the title and the description a reader typed
     * for a post, each cleaned and cut to the length a post may hold.
     *
     * groupFeeds cleaned both into locals before its switch, and the
     * methods the switch now calls stand outside that method, so the ones
     * that write a post ask here. A field the request does not carry
     * comes back as an empty string, which is what the callers test for.
     *
     * @return array the title first and the description second
     */
    public function feedPostFields()
    {
        $parent = $this->parent;
        $title = empty($_REQUEST['title']) ? "" :
            substr($parent->clean($_REQUEST['title'], "string"), 0,
            C\TITLE_LEN);
        $description = empty($_REQUEST['description']) ? "" :
            substr($parent->clean($_REQUEST['description'], "string"), 0,
            C\MAX_GROUP_POST_LEN);
        return [$title, $description];
    }
    /**
     * toggleThreadMail turns mail about a thread on or off for the reader.
     *
     * groupFeeds calls it for the togglethreadmail argument, so each thing
     * a reader can do to a feed is read on its own rather than
     * inside one long switch.
     *
     * @param array $data what the page will show, added to here
     * @param int $user_id which reader is asking
     * @return mixed what is handed back to the browser, or
     *     nothing where the page is drawn as usual
     */
    public function toggleThreadMail(&$data, $user_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $feed_model = $parent->model("feed");
        $just_thread = (empty($_REQUEST["just_thread"]) ? 0 :
            $parent->clean($_REQUEST["just_thread"], "int"));
        $follow_thread = empty($_REQUEST['follow_thread']) ?
            $just_thread :
            $parent->clean($_REQUEST['follow_thread'], "int");
        if (!empty($follow_thread)) {
            $subscribe = !empty($_REQUEST['follow']);
            $feed_model->setThreadSubscribe($user_id,
                $follow_thread, $subscribe);
            if ($subscribe) {
                $message =
                    tl('social_component_thread_followed');
            } else {
                $message =
                    tl('social_component_thread_unfollowed');
            }
            return $parent->redirectWithMessage($message);
        }
    }
    /**
     * voteAnswers splits what a form sent into the questions a ballot records
     * and the answers given to them, in one order, so what is stored and what
     * says which question it belongs to cannot fall out of step. A vote reads
     * back by pairing the two off in order, and any field dropped from one and
     * kept in the other moved every answer along by a place. The sign-in check
     * and the picture puzzle are not questions anybody was asked, so neither is
     * recorded.
     * @param array $csv_headers the fields the form sent, in order
     * @param array $out_row what was given for each of them, in the same order
     * @return array the questions and the answers, as two lists of the same
     *     length
     */
    public function voteAnswers($csv_headers, $out_row)
    {
        $questions = [];
        $answers = [];
        foreach (array_values($csv_headers) as $at => $header) {
            if (in_array($header, ["username", "user_captcha_text",
                "require_signin"])) {
                continue;
            }
            $questions[] = $header;
            $answers[] = $out_row[$at] ?? "";
        }
        return [$questions, $answers];
    }

    /**
     * manageGroups used to handle the manage group activity. This activity
     * allows new groups to be created out of a set of users. It allows admin
     * rights for the group to be transferred and it allows roles to be added to
     * a group. One can also delete groups and roles from groups.
     * @param array $modifiers that affect access to this activity
     * @return array $data information about groups in the system
     */
    public function manageGroups($modifiers)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $possible_arguments = ["activateuser",
            "addgroup", "banuser", "changemailpref", "creategroup",
            "deletegroup", "deleteselected", "deleteuser", "groupsettings",
            "graphstats", "import", "infogroup", "inviteusers", "joingroup",
            "makeeditor", "memberaccess", "postlifetime", "registertype",
            "reinstateuser", "search", "statistics", "unsubscribe",
            "voteaccess"];
        $data["ELEMENT"] = "managegroups";
        $data['SCRIPT'] = "";
        $data['MEMBERSHIP_CODES'] = [
            C\INACTIVE_STATUS => tl('social_component_request_join'),
            C\INVITED_STATUS => tl('social_component_invited'),
            C\ACTIVE_STATUS => tl('social_component_active_status'),
            C\EDITOR_STATUS => tl('social_component_editor_status'),
            C\SUSPENDED_STATUS => tl('social_component_suspended_status')
        ];
        $data['REGISTER_CODES'] = [
            C\INVITE_ONLY_JOIN => tl('social_component_invite_only_join'),
            C\REQUEST_JOIN => tl('social_component_unlisted_by_request'),
            C\PUBLIC_BROWSE_REQUEST_JOIN =>
                tl('social_component_by_request'),
            C\PUBLIC_JOIN => tl('social_component_public_join'),
        ];
        if (in_array(C\p('MONETIZATION_TYPE'),
            ['group_fees', 'fees_and_keywords'])) {
            $data['can_monetise_group'] = true;
            $monetise_codes = [100 => tl('social_component_hundred_credits'),
                200 => tl('social_component_two_hundred_credits'),
                500 => tl('social_component_five_hundred_credits'),
                1000 => tl('social_component_thousand_credits'),
                2000 => tl('social_component_two_thousand_credits')];
            $data['REGISTER_CODES'] += $monetise_codes;
        } else {
            $data['can_monetise_group'] = false;
        }
        $data['ACCESS_CODES'] = [
            C\GROUP_READ => tl('social_component_members_only'),
            C\GROUP_READ_COMMENT => tl('social_component_members_can_comment'),
            C\GROUP_READ_WRITE => tl('social_component_members_start_threads'),
            C\GROUP_READ_WIKI => tl('social_component_members_full_access'),
        ];
        $data['VOTING_CODES'] = [
            C\NON_VOTING_GROUP => tl('social_component_no_voting'),
            C\UP_VOTING_GROUP => tl('social_component_up_voting'),
            C\UP_DOWN_VOTING_GROUP => tl('social_component_up_down_voting')
        ];
        $data['PAGE_SOURCE_CODES'] = [
            1 => tl('social_component_page_source_allowed'),
            0 => tl('social_component_page_source_not_allowed')
        ];
        $data['PAGE_LIST_CODES'] = [
            1 => tl('social_component_page_list_allowed'),
            C\GROUP_OPTION_PAGE_LIST_ARTICLES_SETTING =>
                tl('social_component_page_list_articles'),
            0 => tl('social_component_page_list_not_allowed')
        ];
        $data['PAGE_CUSTOMIZE_CODES'] = [
            1 => tl('social_component_page_customize_allowed'),
            0 => tl('social_component_page_customize_not_allowed')
        ];
        /* The themes a group may draw its pages with, the empty one
           standing for the site's own. A page names one of these too,
           where the group lets a page name its own. */
        $group_themes = $parent->model('profile')->getThemeNames();
        $data['GROUP_THEMES'] = array_merge(
            ["" => tl('social_component_no_auxiliary_theme')],
            array_combine($group_themes, $group_themes));
        $data['POST_LIFETIMES'] = [
            C\FOREVER => tl('social_component_forever'),
            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['ENCRYPTION_CODES'] = [
            1 => tl('social_component_encryption_enable'),
            0 => tl('social_component_encryption_disable'),
        ];
        $data['RENDER_CODES'] = [
            1 => tl('social_component_markdown'),
            0 => tl('social_component_mediawiki'),
        ];
        /* A group may say what kind of page a new page in it starts as. */
        $data['PAGE_TYPES'] = [
            "" => tl('social_component_standard_page'),
            "news_article" => tl('social_component_news_article'),
            "front_page" => tl('social_component_front_page'),
            "page_and_feedback" =>
                tl('social_component_page_and_feedback'),
            "media_list" => tl('social_component_media_list'),
            "presentation" => tl('social_component_presentation')];
        if (in_array(C\p('MONETIZATION_TYPE'), ['group_fees',
            'fees_and_keywords'])) {
            $data['can_monetise_group'] = true;
        } else {
            $data['can_monetise_group'] = false;
        }
        $search_array = [];
        $default_group = ["name" => "", "id" => "", "owner" =>"",
            "register" => -1, "member_access" => -1, 'vote_access' => -1,
            "post_lifetime" => -1, "encryption" => 0,
            "render_engine" => C\MEDIAWIKI_ENGINE,
            "page_source_allowed" => 1, "page_list_allowed" => 1,
            "page_customize_allowed" => 1, "group_theme" => "",
            "page_header" => "", "page_footer" => ""];
        $data['CURRENT_GROUP'] = $default_group;
        $data['PAGING'] = "";
        $name = "";
        $data['visible_users'] = "";
        $is_owner = false;
        if (!isset($_REQUEST['arg'])) {
            $_REQUEST['arg'] = "";
        }
        /* start owner verify code / get current group
           $group_id is only set in this block (except creategroup) and it
           is only not null if $group['OWNER_ID'] == $_SESSION['USER_ID'] where
           this is also the only place group loaded using $group_id
        */
        if (!empty($_REQUEST['group_id'])) {
            $group_id = $parent->clean($_REQUEST['group_id'], "int" );
            $group = $group_model->getGroupById($group_id,
                $_SESSION['USER_ID']);
            $info_no_access = false;
            if (empty($group) && ($_REQUEST['arg'] ?? "") == 'infogroup') {
                $group = $group_model->getGroupById($group_id,
                    C\ROOT_ID);
                $info_no_access = true;
            }
            if (isset($group['OWNER_ID'] ) &&
                ($group['OWNER_ID'] == $_SESSION['USER_ID'] ||
                (isset($_REQUEST['arg']) &&
                ($_SESSION['USER_ID'] == C\ROOT_ID && in_array($_REQUEST['arg'],
                ['statistics', 'graphstats', 'groupsettings'])))) ||
                (isset($_REQUEST['arg']) && in_array($_REQUEST['arg'],
                ['infogroup', 'changemailpref']))) {
                $name = $group['GROUP_NAME'];
                $data['CURRENT_GROUP']['name'] = $name;
                $data['CURRENT_GROUP']['id'] = $group['GROUP_ID'];
                $data['CURRENT_GROUP']['owner'] = $group['OWNER'];
                $data['CURRENT_GROUP']['register'] =
                    $group['REGISTER_TYPE'];
                if ($info_no_access && $group['REGISTER_TYPE'] ==
                    C\INVITE_ONLY_JOIN) {
                    $group_id = false;
                }
                $data['CURRENT_GROUP']['member_access'] =
                    $group['MEMBER_ACCESS'];
                $data['CURRENT_GROUP']['vote_access'] =
                    $group['VOTE_ACCESS'];
                $data['CURRENT_GROUP']['post_lifetime'] =
                    $group['POST_LIFETIME'];
                $data['CURRENT_GROUP']['encryption'] =
                    $group['ENCRYPTION'];
                $data['CURRENT_GROUP']['render_engine'] =
                    $group['RENDER_ENGINE'];
                $data['CURRENT_GROUP']['page_source_allowed'] =
                    $group['PAGE_SOURCE_ALLOWED'];
                $data['CURRENT_GROUP']['page_list_allowed'] =
                    $group['PAGE_LIST_ALLOWED'];
                $data['CURRENT_GROUP']['page_customize_allowed'] =
                    $group['PAGE_CUSTOMIZE_ALLOWED'];
                $data['CURRENT_GROUP']['group_theme'] =
                    $group['GROUP_THEME'] ?? "";
                $data['CURRENT_GROUP']['page_header'] =
                    $group['PAGE_HEADER'] ?? "";
                $data['CURRENT_GROUP']['page_footer'] =
                    $group['PAGE_FOOTER'] ?? "";
                $is_owner = true;
            } else if (!in_array($_REQUEST['arg'],
                ["deletegroup", "joingroup", "unsubscribe"]) &&
                $_SESSION['USER_ID'] != C\ROOT_ID) {
                $group_id = null;
                $group = null;
            }
        } else if (isset($_REQUEST['name'])) {
            $name = substr(trim($parent->clean($_REQUEST['name'], "string")), 0,
                C\SHORT_TITLE_LEN);
            $data['CURRENT_GROUP']['name'] = $name;
            $group_id = null;
            $group = null;
        } else {
            $group_id = null;
            $group = null;
        }
        /* end ownership verify */
        $browse = false;
        $search_name = "manageGroups";
        if (isset($_REQUEST['browse']) && $_REQUEST['browse'] == 'true') {
            $browse = true;
            $data['browse'] = 'true';
            $search_name = "browseGroups";
            $this->initSocialBadges($_SESSION['USER_ID'], $data);
        }
        $data['FORM_TYPE'] = ($browse) ? "" : "addgroup";
        $data['CONTEXT'] = 'groups';
        if (!empty($_REQUEST['context']) && in_array($_REQUEST['context'],
            ['account', 'groups', "join_groups"])) {
            $data['CONTEXT'] = $_REQUEST['context'];
        }
        $data['USER_FILTER'] = "";
        if (isset($_REQUEST['arg']) &&
            in_array($_REQUEST['arg'], $possible_arguments)) {
            switch ($_REQUEST['arg']) {
                case "activateuser":
                case "makeeditor":
                    return $this->activateOrPromoteUsers($data, $group_id,
                        $is_owner);
                case "addgroup":
                    return $this->addUserToGroup($data, $name);
                case "banuser":
                    return $this->banGroupUser($data, $group_id, $is_owner);
                case "creategroup":
                    return $this->createGroup($data, $group_id, $name,
                        $modifiers);
                case "deletegroup":
                    return $this->deleteGroup($data, $group_id, $group,
                        $default_group, $modifiers);
                case "deleteuser":
                    return $this->deleteGroupUser($data, $group_id,
                        $is_owner);
                case "groupsettings":
                    $answer = $this->groupSettings($data, $group_id, $group,
                        $is_owner);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "infogroup":
                    $answer = $this->infoGroup($data, $group_id);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "changemailpref":
                    $answer = $this->changeMailPreference($group_id);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "inviteusers":
                    $answer = $this->inviteGroupUsers($data, $group_id,
                        $is_owner);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "joingroup":
                    return $this->joinGroup($group_id);
                case "graphstats":
                    $answer = $this->groupGraphStats($data, $group_id,
                        $is_owner);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "memberaccess":
                    return $this->setMemberAccess($data, $group);
                case "postlifetime":
                    return $this->setPostLifetime($data, $group);
                case "voteaccess":
                    return $this->setVoteAccess($data, $group);
                case "registertype":
                    return $this->setRegisterType($data, $group);
                case "statistics":
                    $answer = $this->groupStatistics($data, $group_id,
                        $is_owner);
                    if ($answer !== null) {
                        return $answer;
                    }
                    break;
                case "unsubscribe":
                    return $this->unsubscribeFromGroup($group_id);
            }
        }
        $current_id = $_SESSION["USER_ID"];
        $this->initSocialBadges($current_id, $data);
        $data['group_sorts'] = [ "name_asc" =>
            html_entity_decode(tl('social_component_name_asc')),
            "name_desc" => html_entity_decode(tl('social_component_name_desc')),
            "newest_asc" =>
                html_entity_decode(tl('social_component_newest_asc')),
            "newest_desc" =>
                html_entity_decode(tl('social_component_newest_desc')),
        ];
        $data['GROUP_SORT'] =  "name_asc";
        if (!empty($_REQUEST['group_sort']) && in_array($_REQUEST['group_sort'],
            array_keys($data['group_sorts']))) {
            $data['GROUP_SORT'] = $_REQUEST['group_sort'];
        }
        $data["GROUP_FILTER"] = (empty($_REQUEST['group_filter'])) ?
            "" : $parent->clean($_REQUEST['group_filter'], "string");
        $search_array = [];
        if ($data["GROUP_FILTER"]) {
            if ($data["GROUP_FILTER"][0] == '=') {
                $name_clause = ["name", "=", substr($data["GROUP_FILTER"],1)];
            } else {
                $name_clause = ["name", "CONTAINS", $data["GROUP_FILTER"]];
            }
        } else {
            $name_clause = ["name", "", ""];
        }
        $name_clause[3] = ($data['GROUP_SORT'] == "name_asc") ?
            "ASC" : ($data['GROUP_SORT'] == "name_desc" ? "DESC" : "");
        if ($name_clause != ["name", "", "", ""]) {
            $search_array[] = $name_clause;
        }
        $newest_clause = ["created_time", "", ""];
        $newest_clause[3] = ($data['GROUP_SORT'] == "newest_asc") ?
            "ASC" : ($data['GROUP_SORT'] == "newest_desc" ? "DESC" : "");
        if ($newest_clause != ["created_time", "", "", ""]) {
            $search_array[] = $newest_clause;
        }
        if (isset($_SESSION['MAX_PAGES_TO_SHOW']) &&
            $_SESSION['MAX_PAGES_TO_SHOW'] > 0) {
            $results_per_page = $_SESSION['MAX_PAGES_TO_SHOW'];
        } else {
            $results_per_page = C\NUM_RESULTS_PER_PAGE;
        }
        $data['RESULTS_PER_PAGE'] = $results_per_page;
        $_REQUEST['start_row'] = $_REQUEST['limit'] ?? 0;
        $parent->pagingLogic($data, $group_model,
            "GROUPS", $results_per_page, $search_array, "",
            [$current_id, $browse]);
        $data['LIMIT'] = $data['START_ROW'];
        $this->addActivityInfoToGroups($data);
        return $data;
    }
    /**
     * activateOrPromoteUsers makes the users a form named active in a
     * group, or makes them editors of it.
     *
     * manageGroups calls it for the activateuser and makeeditor arguments,
     * which do the same thing with a different standing. Only the owner
     * of a group may do either, and a user who is not already in the
     * group is passed over.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the users belong to
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function activateOrPromoteUsers(&$data, $group_id, $is_owner)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $updated_status = ($_REQUEST['arg'] == "activateuser") ?
             C\ACTIVE_STATUS : C\EDITOR_STATUS;
        $_REQUEST['arg'] = "groupsettings";
        $num_activated = 0;
        if ($is_owner && !empty($_REQUEST['user_ids'])) {
            $user_ids = $_REQUEST['user_ids'];
            $ids = explode("*", $user_ids);
            foreach ($ids as $user_id) {
                $user_id = (!empty($user_id)) ?
                    $parent->clean($user_id, 'int'): 0;
                if ($group_model->checkUserGroup($user_id,
                    $group_id)) {
                    $group_model->updateStatusUserGroup($user_id,
                        $group_id, $updated_status);
                    $num_activated++;
                }
            }
        }
        $this->getGroupUsersData($data, $group_id);
        if ($num_activated == 1) {
            $message = ($updated_status == C\ACTIVE_STATUS) ?
                tl('social_component_user_activated') :
                tl('social_component_user_editor');
            return $parent->redirectWithMessage($message,
                ["arg", 'context', 'end_row', 'group_limit',
                'num_show', 'start_row', 'user_filter',
                'user_sorts', "visible_users"]);
        } else if ($num_activated > 1) {
            $message = ($updated_status == C\ACTIVE_STATUS) ?
                tl('social_component_users_activated') :
                tl('social_component_users_editors');
            return $parent->redirectWithMessage($message,
                ["arg", 'context', 'end_row', 'group_limit',
                'num_show', 'start_row', 'user_filter',
                'user_sorts', "visible_users"]);
        }
        $message = ($updated_status == C\ACTIVE_STATUS) ?
            tl('social_component_no_user_activated') :
            tl('social_component_no_user_editor');
        return $parent->redirectWithMessage($message,
            ["arg", 'context', 'end_row', 'group_limit',
            'num_show', 'start_row', 'user_filter',
            'user_sorts', "visible_users"]);
    }
    /**
     * addUserToGroup asks to join the group a reader named.
     *
     * manageGroups calls it for the addgroup argument. A group nobody may
     * join by asking is refused, and so is one the reader is already in.
     *
     * @param array &$data what the page will show, added to here
     * @param string $name name of the group the reader typed
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function addUserToGroup(&$data, $name)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        if (($add_id = $group_model->getGroupId($name)) <= 0) {
            return $parent->redirectWithMessage(
                tl('social_component_group_doesnt_exist'),
                ['arg', 'context', 'end_row',
                'group_limit', 'name', 'num_show', 'start_row',
                'user_filter', 'user_sorts',"visible_users"]);
        }
        $register =
            $group_model->getRegisterType($add_id);
        if ($register >= C\LOW_JOIN_FEE &&
            !in_array(C\p('MONETIZATION_TYPE'),
            ['group_fees','fees_and_keywords'])) {
            $register = C\INVITE_ONLY_JOIN;
        }
        if ((!empty($register) && $register !=C\INVITE_ONLY_JOIN) ||
            $_SESSION['USER_ID'] == C\ROOT_ID) {
            return $this->addGroup($data, $add_id, $register);
        } else {
            return $parent->redirectWithMessage(
                tl('social_component_groupname_cant_add'));
        }
    }
    /**
     * banGroupUser suspends the users a form named from a group.
     *
     * manageGroups calls it for the banuser argument. Only the owner of a
     * group may suspend anybody in it, and the owner cannot be suspended.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the users belong to
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function banGroupUser(&$data, $group_id, $is_owner)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $_REQUEST['arg'] = "groupsettings";
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context']=='search') {
            $data['CONTEXT'] = 'search';
        };
        $banned = 0;
        if ($is_owner && !empty($_REQUEST['user_ids'])) {
            $user_ids = $_REQUEST['user_ids'];
            $ids = explode("*", $user_ids);
            foreach ($ids as $user_id) {
                $user_id = (!empty($user_id)) ?
                    $parent->clean($user_id, 'int'): 0;
                if ($group_model->checkUserGroup($user_id,
                    $group_id)) {
                    $group_model->updateStatusUserGroup($user_id,
                        $group_id, C\SUSPENDED_STATUS);
                    $banned++;
                }
            }
        }
        $this->getGroupUsersData($data, $group_id);
        if ($banned == 1) {
            return $parent->redirectWithMessage(
                tl('social_component_user_banned'),
                ["arg", 'context', 'end_row', 'group_limit',
                'num_show', 'start_row','user_filter',
                'user_sorts',"visible_users"]);
        } else  if ($banned > 1) {
            return $parent->redirectWithMessage(
                tl('social_component_users_banned'),
                ["arg", 'context', 'end_row', 'group_limit',
                'num_show', 'start_row', 'user_filter',
                'user_sorts',"visible_users"]);
        }
        return $parent->redirectWithMessage(
            tl('social_component_no_user_banned'),
            ["arg", 'context', 'end_row', 'group_limit',
            'num_show', 'start_row', 'user_filter',
            'user_sorts',"visible_users"]);
    }
    /**
     * createGroup makes a new group with the name and settings a form
     * carried.
     *
     * manageGroups calls it for the creategroup argument. A reader whose
     * account is not allowed to make groups is refused, and so is a name
     * some other group already answers to.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the request named, if any
     * @param string $name name the reader typed for the new group
     * @param array $modifiers what the reader's account may not do
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function createGroup(&$data, $group_id, $name, $modifiers)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        if (in_array("cannot_create_groups", $modifiers)) {
            return $parent->redirectWithMessage(
                tl('social_component_user_cant_create'));
        } else if ($_SESSION['USER_ID'] == C\PUBLIC_USER_ID) {
            return $parent->redirectWithMessage(
                tl('social_component_public_cant_create'));
        } else if ($group_model->getGroupId($name) > 0) {
            return $parent->redirectWithMessage(
                tl('social_component_groupname_exists'));
        } else if (!empty($name)) {
            $group_fields = [
                "member_access" => ["ACCESS_CODES", C\GROUP_READ],
                "register" => ["REGISTER_CODES", C\REQUEST_JOIN],
                "vote_access" => ["VOTING_CODES",
                    C\NON_VOTING_GROUP],
                "post_lifetime" => ["POST_LIFETIMES", C\FOREVER],
                "encryption" => ["ENCRYPTION_CODES", 0],
                "render_engine" => ["RENDER_CODES",
                    C\MEDIAWIKI_ENGINE]
            ];
            foreach ($group_fields as $field => $info) {
                if (!isset($_REQUEST[$field]) ||
                    !in_array($_REQUEST[$field],
                    array_keys($data[$info[0]]))) {
                    $_REQUEST[$field] = $info[1];
                }
            }
            if ($this->overUserGroupLimit(
                $_SESSION['USER_ID'], 'MAX_GROUPS_OWNED',
                $group_model->countGroupsOwnedByUser(
                $_SESSION['USER_ID']))) {
                return $parent->redirectWithMessage(
                    tl('social_component_groups_owned_full'));
            }
            $group_model->addGroup($name,
                $_SESSION['USER_ID'],
                $_REQUEST['register'],
                $_REQUEST['member_access'],
                $_REQUEST['vote_access'],
                $_REQUEST['post_lifetime'],
                $_REQUEST['encryption'],
                $_REQUEST['render_engine']);
            //one exception to setting $group_id
            $group_id = $group_model->getGroupId($name);
            return $parent->redirectWithMessage(
                tl('social_component_groupname_created'),
                ["arg", 'start_row', 'end_row', 'num_show']);
        }
    }
    /**
     * deleteGroup takes a group away, with everything posted in it.
     *
     * manageGroups calls it for the deletegroup argument. Only the owner
     * may delete a group, and a reader whose account is not allowed to
     * delete groups is refused.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group to take away
     * @param array $group what the model holds about that group
     * @param array $default_group an empty group, put back on the page once
     *     the group is gone
     * @param array $modifiers what the reader's account may not do
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function deleteGroup(&$data, $group_id, $group, $default_group,
        $modifiers)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $_REQUEST['arg'] = empty($_REQUEST['context']) ?
            'none': 'search';
        $data['CURRENT_GROUP'] = $default_group;
        if (in_array("cannot_delete_groups", $modifiers)) {
            return $parent->redirectWithMessage(
                tl('social_component_user_cant_delete'));
        } else
        if ( $group_id <= 0) {
            return $parent->redirectWithMessage(
              tl('social_component_groupname_doesnt_exists'),
              ["arg"]);
        } else if (($group &&
            $group['OWNER_ID'] == $_SESSION['USER_ID']) ||
            $_SESSION['USER_ID'] == C\ROOT_ID) {
            $group_model->deleteGroup($group_id);
            unset($_REQUEST['route']);
            $_REQUEST['c'] = "group";
            return $parent->redirectWithMessage(
                tl('social_component_group_deleted'), ["arg"]);
        }
        return $parent->redirectWithMessage(
            tl('social_component_no_delete_group'),
            ["arg", 'start_row', 'end_row', 'num_show']);
    }
    /**
     * deleteGroupUser takes the users a form named out of a group.
     *
     * manageGroups calls it for the deleteuser argument. Only the owner
     * may remove anybody, and the owner cannot be removed from their own
     * group.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the users belong to
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function deleteGroupUser(&$data, $group_id, $is_owner)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $_REQUEST['arg'] = "groupsettings";
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context']=='search') {
            $data['CONTEXT'] = 'search';
        }
        $deleted = 0;
        if ($is_owner && !empty($_REQUEST['user_ids'])) {
            $user_ids = $_REQUEST['user_ids'];
            $ids = explode("*", $user_ids);
            foreach ($ids as $user_id) {
                $user_id = (!empty($user_id)) ?
                    $parent->clean($user_id, 'int'): 0;
                if ($group_model->deletableUser($user_id,
                    $group_id)) {
                    $group_model->deleteUserGroup(
                        $user_id, $group_id);
                    $deleted++;
                }
            }
        }
        if ($deleted == 1) {
            return $parent->redirectWithMessage(
                tl('social_component_user_deleted'),
                ["arg", 'context', 'end_row', 'group_limit',
                'num_show', 'start_row', 'user_filter',
                'user_sorts',"visible_users"]);
        } else if ($deleted > 1) {
            return $parent->redirectWithMessage(
                tl('social_component_users_deleted'),
                ["arg", 'context', 'end_row', 'group_limit',
                'num_show', 'start_row', 'user_filter',
                'user_sorts',"visible_users"]);
        }
        return $parent->redirectWithMessage(
            tl('social_component_no_delete_user_group'),
            ["arg", 'context', 'end_row', 'group_limit',
            'num_show', 'start_row', 'user_filter',
            'user_sorts',"visible_users"]);
    }
    /**
     * groupSettings draws the settings of one group and saves what a
     * form changed.
     *
     * manageGroups calls it for the groupsettings argument. It is the
     * longest of these because a group carries many settings, and each
     * is checked against what it may hold before it is saved.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group is being set up
     * @param array $group what the model holds about that group
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function groupSettings(&$data, $group_id, $group, $is_owner)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if (!$group_id ||
            (!$is_owner && $_SESSION['USER_ID'] != C\ROOT_ID)) {
            return;
        }
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context']=='search') {
            $data['CONTEXT'] = 'search';
        }
        $data['FORM_TYPE'] = "groupsettings";
        $mail_pref_submitted =
            !empty($_REQUEST['mail_pref_shown']) &&
            $group_model->getMailSubscription(
            $_SESSION['USER_ID'], $group_id) !== null;
        if ($mail_pref_submitted) {
            $group_model->setMailSubscription(
                $_SESSION['USER_ID'], $group_id,
                empty($_REQUEST['receive_group_mail']) ? 0 : 1);
        }
        $update_fields = [
            ['owner', "OWNER", "valid_user"],
            ['member_access', 'MEMBER_ACCESS', 'ACCESS_CODES'],
            ['register', 'REGISTER_TYPE','REGISTER_CODES'],
            ['vote_access', 'VOTE_ACCESS', 'VOTING_CODES'],
            ['post_lifetime', 'POST_LIFETIME', 'POST_LIFETIMES'],
            ['encryption', 'ENCRYPTION', 'ENCRYPTION_CODES'],
            ['render_engine', 'RENDER_ENGINE', 'RENDER_CODES'],
            ['page_source_allowed', 'PAGE_SOURCE_ALLOWED',
                'PAGE_SOURCE_CODES'],
            ['page_list_allowed', 'PAGE_LIST_ALLOWED',
                'PAGE_LIST_CODES'],
            ['page_customize_allowed', 'PAGE_CUSTOMIZE_ALLOWED',
                'PAGE_CUSTOMIZE_CODES']
            ];
        $message = $this->updateGroup($data, $group,
            $update_fields);
        /* The names a group files under and the kind a new
           page starts as are a line of text and a choice, so
           neither fits the mapping above. */
        if (isset($_REQUEST['page_categories'])) {
            $named = $parent->clean(
                $_REQUEST['page_categories'], "string");
            $starts = $parent->clean(
                $_REQUEST['default_page_type'] ?? "", "string");
            $wiki_model->setGroupPageSettings(
                $group['GROUP_ID'], explode(",", $named),
                $starts);
        }
        /* The look a group gives a page that names none of
           its own: a theme and the pages to stand above and
           below. Each is a name rather than a choice among
           codes, so none fits the mapping above either. */
        if (isset($_REQUEST['group_theme'])) {
            $group_model->setGroupAppearance(
                $group['GROUP_ID'],
                $parent->clean($_REQUEST['group_theme'],
                "string"),
                $parent->clean($_REQUEST['page_header'] ?? "",
                "string"),
                $parent->clean($_REQUEST['page_footer'] ?? "",
                "string"));
        }
        if (!empty($message) || $mail_pref_submitted) {
            $preserve_fields = ['arg', 'browse', 'context',
                'start_row','end_row', 'group_limit', 'num_show',
                'user_filter', 'user_sorts', 'visible_users'];
            if ($message == tl('social_component_owner_updated')) {
                $preserve_fields = ['arg', 'browse', 'start_row',
                    'end_row', 'group_limit', 'num_show',
                    'user_filter', 'user_sorts', 'visible_users'];
            }
            return $parent->redirectWithMessage($message ?:
                tl('social_component_group_updated'),
                $preserve_fields);
        }
        $data['CURRENT_GROUP']['register'] =
            $group['REGISTER_TYPE'];
        $data['CURRENT_GROUP']['member_access'] =
            $group['MEMBER_ACCESS'];
        $data['CURRENT_GROUP']['vote_access'] =
            $group['VOTE_ACCESS'];
        $data['CURRENT_GROUP']['post_lifetime'] =
            $group['POST_LIFETIME'];
        $data['CURRENT_GROUP']['encryption'] =
            $group['ENCRYPTION'];
        $data['CURRENT_GROUP']['render_engine'] =
            $group['RENDER_ENGINE'];
        /* The names this group files under and the kind a new
           page starts as, so its form shows what it holds. */
        $kept = $wiki_model->getGroupPageSettings($group_id);
        $data['CURRENT_GROUP']['page_categories'] =
            implode(", ", $kept['categories']);
        $data['CURRENT_GROUP']['default_page_type'] =
            $kept['default_page_type'];
        $data['SCRIPT'] .= "listenAll('input.user-id', 'click',".
            " updateCheckedUserIds);";
        $data['SCRIPT'] .= "initGroupCategoryList();";
        $this->getGroupUsersData($data, $group_id);
        if (!empty($_FILES['DISCUSSION_DATA']['tmp_name'])) {
            if (empty($_FILES['DISCUSSION_DATA']['data'])) {
                $feed_data = $parent->web_site->fileGetContents(
                    $_FILES['DISCUSSION_DATA']['tmp_name']);
            } else {
                $feed_data = $_FILES['DISCUSSION_DATA']['data'];
            }
            $this->importDiscussions($group_id,
                $group['OWNER_ID'], $feed_data);
        }
        $parent->component("mail")->addMailPrefData($data, $group_id);
        $data['SCRIPT'] .= "elt('focus-button').focus();";
    }
    /**
     * infoGroup draws what a reader may see about a group they are not
     * in.
     *
     * manageGroups calls it for the infogroup argument. A group nobody may
     * find by looking is not described, however the reader reached it.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group to describe
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function infoGroup(&$data, $group_id)
    {
        $parent = $this->parent;
        if (!$group_id) {
            return $parent->redirectWithMessage(
              tl('social_component_groupname_lookup_error'));
        }
        $user_model = $parent->model("user");
        $data['FORM_TYPE'] = "infogroup";
        $this->getGroupUsersData($data, $group_id);
        $owner_id = $user_model->getUserId(
            $data['CURRENT_GROUP']['owner']);
        $data['CURRENT_GROUP']['owner_id'] = $owner_id;
        $data['CURRENT_GROUP']['user_icon'] =
            $user_model->getUserIconUrl($owner_id);
        $parent->component("mail")->addMailPrefData($data, $group_id);
    }
    /**
     * changeMailPreference turns mail about a group on or off for the
     * reader.
     *
     * manageGroups calls it for the changemailpref argument. It is the
     * same setting a reader can reach from a group's feed.
     *
     * @param int $group_id which group the setting is for
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function changeMailPreference($group_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        if (!$group_id) {
            return $parent->redirectWithMessage(
                tl('social_component_groupname_lookup_error'));
        }
        $member_status = $group_model->checkUserGroup(
            $_SESSION['USER_ID'], $group_id);
        if ($member_status === false ||
            $member_status === C\NOT_MEMBER_STATUS) {
            return $parent->redirectWithMessage(
                tl('social_component_no_permission'));
        }
        $receive = empty($_REQUEST['receive_group_mail'])
            ? 0 : 1;
        $group_model->setMailSubscription(
            $_SESSION['USER_ID'], $group_id, $receive);
        $_REQUEST['arg'] = (!empty($_REQUEST['return']) &&
            $_REQUEST['return'] === 'groupsettings') ?
            'groupsettings' : 'infogroup';
        return $parent->redirectWithMessage(
            tl('social_component_group_updated'),
            ['arg', 'context']);
    }
    /**
     * inviteGroupUsers invites the people a form named into a group.
     *
     * manageGroups calls it for the inviteusers argument. Only the owner
     * may invite, and a name no account answers to is reported rather
     * than passed over.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the invitations are to
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function inviteGroupUsers(&$data, $group_id, $is_owner)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $data['FORM_TYPE'] = "inviteusers";
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context']=='search') {
            $data['CONTEXT'] = 'search';
        }
        if (isset($_REQUEST['users_names']) && $is_owner) {
            $users_string = $parent->clean($_REQUEST['users_names'],
                "string");
            $pre_user_names = preg_split("/\s+|\,/", $users_string);
            $users_invited = false;
            foreach ($pre_user_names as $user_name) {
                $user_name = trim($user_name);
                $user = $parent->model("user")->getUser($user_name);
                if ($user) {
                    if (!$group_model->checkUserGroup(
                        $user['USER_ID'], $group_id)) {
                        $this->addUserGroupWithinLimits(
                            $user['USER_ID'], $group_id,
                            C\INVITED_STATUS);
                        $users_invited = true;
                    }
                }
            }
            $_REQUEST['arg'] = "groupsettings";
            if ($users_invited) {
                return $parent->redirectWithMessage(
                    tl('social_component_users_invited'),
                    ["arg", 'context', 'end_row', 'group_limit',
                    'num_show', 'start_row', 'user_filter',
                    'user_sorts',"visible_users"]);
            } else {
                return $parent->redirectWithMessage(
                    tl('social_component_no_users_invited'),
                    ["arg", 'context', 'end_row', 'group_limit',
                    'num_show', 'start_row', 'user_filter',
                    'user_sorts',"visible_users"]);
            }
        }
    }
    /**
     * joinGroup puts the reader into a group, or asks to be let in.
     *
     * manageGroups calls it for the joingroup argument. Which of the two
     * happens is settled by how the group takes new members.
     *
     * @param int $group_id which group the reader is joining
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function joinGroup($group_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context'] == 'search') {
            $_REQUEST['arg'] = 'search';
        } else {
            $_REQUEST['arg'] = 'none';
        }
        $user_id = (isset($_REQUEST['user_id'])) ?
            $parent->clean($_REQUEST['user_id'], 'int'): 0;
        if (empty($group_id) && !empty($_REQUEST['name'])) {
            $group_id = $group_model->getGroupId(
                $parent->clean($_REQUEST['name'], 'string'));
        }
        if ($user_id && $group_id &&
            $group_model->checkUserGroup($user_id,
                $group_id, C\INVITED_STATUS)) {
            $group_model->updateStatusUserGroup($user_id,
                $group_id, C\ACTIVE_STATUS);
            unset($_REQUEST['route']);
            $_REQUEST['c'] = "group";
            return $parent->redirectWithMessage(
                tl('social_component_joined'),
                ['arg']);
        }
        return $parent->redirectWithMessage(
            tl('social_component_no_join'),
            ['arg', 'browse', 'start_row', 'end_row',
            'num_show']);
    }
    /**
     * groupGraphStats draws how much a group was read over time.
     *
     * manageGroups calls it for the graphstats argument. Only the owner of
     * a group, or the root account, may see these.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the figures are for
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function groupGraphStats(&$data, $group_id, $is_owner)
    {
        $parent = $this->parent;
        if (!$group_id || (!$is_owner &&
            $_SESSION['USER_ID'] != C\ROOT_ID)) {
            return;
        }
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context']=='search') {
            $data['CONTEXT'] = 'search';
        };
        $period = C\ONE_DAY;
        if (in_array($_REQUEST['time'], [C\ONE_DAY, C\ONE_MONTH,
            C\ONE_YEAR])) {
            $period = $_REQUEST['time'];
        }
        $impression_type = C\THREAD_IMPRESSION;
        if (in_array($_REQUEST['impression'], [C\THREAD_IMPRESSION,
            C\WIKI_IMPRESSION, C\GROUP_IMPRESSION,
            C\QUERY_IMPRESSION])) {
            $impression_type = $_REQUEST['impression'];
        }
        $item_id = $parent->clean($_REQUEST['item'], "int");
        $this->makeImpressionChart($data, $impression_type,
            $period, $item_id);
        $this->getGroupUsersData($data, $group_id);
        $data['SCRIPT'] .= "elt('focus-button').focus();";
    }
    /**
     * setMemberAccess reads back how much a group's members may do.
     *
     * manageGroups calls it for the memberaccess argument, which a form
     * sends as the reader changes the setting rather than on saving.
     *
     * @param array &$data what the page will show, added to here
     * @param array $group what the model holds about the group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function setMemberAccess(&$data, $group)
    {
        $parent = $this->parent;
        $update_fields = [
            ['memberaccess', 'MEMBER_ACCESS', 'ACCESS_CODES']];
        $message =
            $this->updateGroup($data, $group, $update_fields);
        $_REQUEST['arg'] = empty($_REQUEST['context']) ?
            'none': 'search';
        unset($_REQUEST['group_id']);
        return $parent->redirectWithMessage($message,
            ['arg', 'browse', 'start_row', 'end_row',
            'num_show', 'visible_users', 'user_filter']);
    }
    /**
     * setPostLifetime reads back how long a post in a group is kept.
     *
     * manageGroups calls it for the postlifetime argument, which a form
     * sends as the reader changes the setting rather than on saving.
     *
     * @param array &$data what the page will show, added to here
     * @param array $group what the model holds about the group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function setPostLifetime(&$data, $group)
    {
        $parent = $this->parent;
        $update_fields = [
            ['postlifetime', 'POST_LIFETIME', 'POST_LIFETIMES']];
        $message =
            $this->updateGroup($data, $group, $update_fields);
        $_REQUEST['arg'] = empty($_REQUEST['context']) ?
            'none': 'search';
        unset($_REQUEST['group_id']);
        return $parent->redirectWithMessage($message,
            ['arg', 'browse', 'start_row', 'end_row',
            'num_show', 'visible_users', 'user_filter']);
    }
    /**
     * setVoteAccess reads back how a group's members may vote.
     *
     * manageGroups calls it for the voteaccess argument, which a form
     * sends as the reader changes the setting rather than on saving.
     *
     * @param array &$data what the page will show, added to here
     * @param array $group what the model holds about the group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function setVoteAccess(&$data, $group)
    {
        $parent = $this->parent;
        $update_fields = [
            ['voteaccess', 'VOTE_ACCESS', 'VOTING_CODES']];
        $message =
            $this->updateGroup($data, $group, $update_fields);
        $_REQUEST['arg'] = empty($_REQUEST['context']) ?
            'none': 'search';
        unset($_REQUEST['group_id']);
        return $parent->redirectWithMessage($message,
            ['arg', 'browse', 'start_row', 'end_row',
            'num_show', 'visible_users', 'user_filter']);
    }
    /**
     * setRegisterType reads back how a group takes new members.
     *
     * manageGroups calls it for the registertype argument, which a form
     * sends as the reader changes the setting rather than on saving.
     *
     * @param array &$data what the page will show, added to here
     * @param array $group what the model holds about the group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function setRegisterType(&$data, $group)
    {
        $parent = $this->parent;
        $update_fields = [
            ['registertype', 'REGISTER_TYPE',
                'REGISTER_CODES']];
        $message =
            $this->updateGroup($data, $group, $update_fields);
        $_REQUEST['arg'] = empty($_REQUEST['context']) ?
            'none': 'search';
        unset($_REQUEST['group_id']);
        return $parent->redirectWithMessage($message,
            ['arg', 'browse', 'start_row', 'end_row',
            'num_show', 'visible_users', 'user_filter']);
    }
    /**
     * groupStatistics draws the figures for one group: how many posts,
     * threads and pages it holds and who wrote them.
     *
     * manageGroups calls it for the statistics argument. Only the owner of
     * a group, or the root account, may see these.
     *
     * @param array &$data what the page will show, added to here
     * @param int $group_id which group the figures are for
     * @param bool $is_owner whether the reader owns that group
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function groupStatistics(&$data, $group_id, $is_owner)
    {
        $parent = $this->parent;
        if (!$group_id || (!$is_owner &&
            $_SESSION['USER_ID'] != C\ROOT_ID)) {
                return;
        }
        if (!empty($_REQUEST['context']) &&
            $_REQUEST['context']=='search') {
            $data['CONTEXT'] = 'search';
        };
        $data['FORM_TYPE'] = "statistics";
        $impression_model = $parent->model("impression");
        $periods = [C\ONE_HOUR, C\ONE_DAY, C\ONE_MONTH, C\ONE_YEAR,
            C\FOREVER];
        $stat_types = [C\GROUP_IMPRESSION, C\THREAD_IMPRESSION,
            C\WIKI_IMPRESSION];
        $filter = (empty($_REQUEST['filter'])) ? "" :
            $parent->clean($_REQUEST['filter'], 'string');
        $data['FILTER'] = $filter;
        foreach ($periods as $period) {
            $data["STATISTICS"][C\GROUP_IMPRESSION][$period] =
                $impression_model->getStatistics(C\GROUP_IMPRESSION,
                $period, $filter, $group_id);
            $data["STATISTICS"][C\THREAD_IMPRESSION][$period] =
                $impression_model->getStatistics(
                C\THREAD_IMPRESSION, $period,  $filter, $group_id);
            $data["STATISTICS"][C\WIKI_IMPRESSION][$period] =
                $impression_model->getStatistics(C\WIKI_IMPRESSION,
                $period, $filter, $group_id);
        }
        if (C\p('DIFFERENTIAL_PRIVACY')) {
            $this->socialPrivacy($data, $group_id);
        }
        $this->getGroupUsersData($data, $group_id);
        $data['SCRIPT'] .= "elt('focus-button').focus();";
    }
    /**
     * unsubscribeFromGroup takes the reader out of a group they joined.
     *
     * manageGroups calls it for the unsubscribe argument. The owner of a
     * group cannot leave it, since a group with no owner has nobody to
     * set it up.
     *
     * @param int $group_id which group the reader is leaving
     * @return mixed what is handed back to the browser, or
     *     null where the page is drawn as usual
     */
    private function unsubscribeFromGroup($group_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        if (!empty($_REQUEST['context'])) {
            $_REQUEST['arg'] = 'search';
        } else {
            $_REQUEST['arg'] = 'none';
        }
        $user_id = (isset($_REQUEST['user_id'])) ?
            $parent->clean($_REQUEST['user_id'], 'int'): 0;
        unset($_REQUEST['route']);
        $_REQUEST['c'] = "group";
        if ($user_id && $group_id &&
            $group_model->checkUserGroup($user_id,
            $group_id)) {
            $group_model->deleteUserGroup($user_id,
                $group_id);
            return $parent->redirectWithMessage(
                tl('social_component_unsubscribe'),
                ['arg','start_row', 'end_row', 'num_show']);
        }
        return $parent->redirectWithMessage(
            tl('social_component_no_unsubscribe'),
            ['arg', 'start_row', 'end_row', 'num_show']);
    }
    /**
     * manageGroupsModifiers what modifiers of the manageLocales activity can be
     * added in manageRoles
     * @return array key value pairs modifier_name => localized description
     */
    public function manageGroupsModifiers()
    {
        return [
                "cannot_create_groups" =>
                    tl("system_component_cannot_create_groups"),
                "cannot_delete_groups" =>
                    tl("system_component_cannot_delete_groups"),
            ];
    }

    /**
     * makeImpressionChart used to handle request related to usage statistics
     * for groups
     * @param array &$data fields to be sent to the view with chart data
     * @param int $impression_type what type $item_id is. Could have values:
     *     C\WIKI_IMPRESSION, C\THREAD_IMPRESSION, C\GROUP_IMPRESSION,
     *     C\QUERY_IMPRESSION
     * @param int $period C\ONE_HOUR, C\ONE_DAY, and so on that the chart is
     *     drawn
     *     should be for
     * @param int $item_id id of group, wiki page, thread, chart should be for
     * @param string $chart_name string identifier echoed into the rendered view
     *     to label this chart instance (so multiple charts on one page don't
     *     collide)
     * @param string $chart_id DOM id used by the client-side chart code to bind
     *     the rendered canvas element to its data
     */
    public function makeImpressionChart(&$data, $impression_type,
        $period, $item_id, $chart_name = "chart", $chart_id = "chart")
    {
        $parent = $this->parent;
        $data['FORM_TYPE'] = "graphstats";
        $data['INCLUDE_SCRIPTS'][] = "chart";
        $impression_model = $parent->model("impression");
        $data["STATISTICS"][$period] =
            $impression_model->getPeriodHistogramData(
            $impression_type, $period, $item_id);
        $graph_data = [];
        if ($period == C\ONE_DAY) {
            $column_name = tl('social_component_hour');
            $dt_format = "H";
            $now = date("H");
            for ($i = 0 ; $i < 24; $i++) {
                $graph_data[" ".(($now + $i) % 24 + 1)] = 0;
            }
        } else if ($period == C\ONE_MONTH) {
            $column_name = tl('social_component_day');
            $dt_format = "d";
            $now = date("d");
            for ($i = 0 ; $i < 31; $i++) {
                $graph_data[" ".(($now + $i) % 31 +1)] = 0;
            }
        } else if ($period == C\ONE_YEAR) {
            $column_name = tl('social_component_month');
            $dt_format = "M";
            $now = date("n");
            $months = [tl('social_component_jan'),
                tl('social_component_feb'),
                tl('social_component_mar'),
                tl('social_component_apr'),
                tl('social_component_may'),
                tl('social_component_jun'),
                tl('social_component_jul'),
                tl('social_component_aug'),
                tl('social_component_sep'),
                tl('social_component_oct'),
                tl('social_component_nov'),
                tl('social_component_dec')];
            for ($i = 0 ; $i < 12; $i++) {
                $graph_data[$months[(($now + $i) % 12 )]] = 0;
            }
        }
        $graph_title = tl('social_component_visits', $column_name);
        foreach ($data['STATISTICS'][$period] as $key =>
            $statistics_value) {
            $timestamp = $statistics_value['UPDATE_TIMESTAMP'];
            $dt = date($dt_format, $timestamp);
            if ($period != C\ONE_YEAR) {
                $graph_data[" ".intval($dt)] =
                    $statistics_value['VIEWS'];
            } else {
                $graph_data[$dt] =
                    $statistics_value['VIEWS'];
            }
        }
        $graph_data = json_encode($graph_data);
        if ($_SERVER["MOBILE"]) {
            $properties = ["title" => $graph_title,
                "width" => 340, "height" => 300,
                "tick_font_size" => 8];
        } else {
            $properties = ["title" => $graph_title,
                "width" => 700, "height" => 500];
        }
        $properties = json_encode($properties);
        $data['SCRIPT'] .= "$chart_name = new Chart(" .
            '"'. $chart_id . '", '. $graph_data .
            ', '. $properties . "); $chart_name.draw();";
    }
    /**
     * socialPrivacy adds the noise that keeps a single reader from
     * being picked out of the figures a group shows: it walks the
     * counts already gathered and blurs each, never letting a blurred
     * figure rise above the one before it.
     *
     * @param array $data the values the screen will be drawn from,
     *      whose STATISTICS entry holds the counts being blurred
     * @param int $group_id the group whose figures these are, needed
     *      to read the key a thread name was encrypted with
     */
    public function socialPrivacy(&$data, $group_id)
    {
        $parent = $this->parent;
        $impression_model = $parent->model("impression");
        $group_model = $parent->model("group");
        $periods = [C\ONE_HOUR, C\ONE_DAY, C\ONE_MONTH, C\ONE_YEAR,
            C\FOREVER];
        $stat_types = [C\GROUP_IMPRESSION, C\THREAD_IMPRESSION,
            C\WIKI_IMPRESSION];
        /* The figures already blurred, kept so a figure never rises
           when the noise added to it happens to fall. */
        $tmp_data = [];
        foreach ($stat_types as $field) {
            $i = 0;
            foreach ($periods as  $period) {
                if ($field == C\GROUP_IMPRESSION) {
                    if (!empty($data["STATISTICS"][$field][$period])) {
                        $view_stat = $impression_model->getImpressionStat(
                            $data["STATISTICS"][$field][$period][0]['ID'],
                            $field, $period);
                        $fuzzy_views = $view_stat[1];
                        if (empty($view_stat[0]) ||
                            $view_stat[0] != $data["STATISTICS"][$field][
                            $period][0]['NUM_VIEWS'] ||
                            $tmp_data[$i - 1] > $fuzzy_views) {
                            $fuzzy_views = $parent->addDifferentialPrivacy(
                                $data["STATISTICS"][$field][
                                $period][0]['NUM_VIEWS']);
                            /* Make sure each time period's
                            fuzzified view is at least as large as
                            previous time period's value */
                            if ($i > 0) {
                                if ($tmp_data[$i - 1] > $fuzzy_views) {
                                    $fuzzy_views = $tmp_data[$i - 1];
                                }
                            }
                            $impression_model->updateImpressionStat(
                                $data["STATISTICS"][$field][$period][0]['ID'],
                                $field, $period, $data["STATISTICS"][$field][
                                    $period][0]['NUM_VIEWS'],
                                $fuzzy_views);
                        }
                        $data["STATISTICS"][$field][$period][0]
                            ['NUM_VIEWS'] = ($fuzzy_views == 0)?
                            tl('managegroups_element_no_activity'):
                            $fuzzy_views;
                        $tmp_data[$i] = $fuzzy_views;
                        $i++;
                    }
                } else {
                    if (!empty($data['STATISTICS'][$field][$period])) {
                        foreach ($data['STATISTICS'][$field]
                            [$period] as $item_name =>
                            $item_data) {
                            $view_stat = $impression_model->getImpressionStat(
                                $item_data[0]['ID'], $field, $period);
                            $fuzzy_views = $view_stat[1];
                            if ($view_stat[0] != $item_data[0]['NUM_VIEWS'] ||
                                $tmp_data[$item_name][$i - 1] > $fuzzy_views) {
                                $fuzzy_views = $parent->addDifferentialPrivacy(
                                    $item_data[0]['NUM_VIEWS']);
                                if ($i > 0) {
                                    if ($tmp_data[$item_name][$i-1] >
                                        $fuzzy_views) {
                                        $fuzzy_views =
                                            $tmp_data[$item_name][$i - 1];
                                    }
                                }
                                $impression_model->updateImpressionStat(
                                    $item_data[0]['ID'],
                                    $field, $period, $item_data[0]['NUM_VIEWS'],
                                    $fuzzy_views);
                            }
                            $data["STATISTICS"][$field][$period][
                                $item_name][0]['NUM_VIEWS']=
                                ($fuzzy_views == 0) ?
                                tl('managegroups_element_no_activity'):
                                $fuzzy_views;
                            $tmp_data[$item_name][$i] =
                                $fuzzy_views;
                            /* Decrypt group items if encrypted before
                                displaying */
                            if ($field == C\THREAD_IMPRESSION &&
                                $group_model->isGroupEncrypted($group_id)) {
                                // Decrypt thread's title
                                $key = $group_model->getGroupKey($group_id);
                                $decrypted_item_name = $group_model->decrypt(
                                    $item_name, $key);
                                $data['STATISTICS'][$field][$period][
                                    $decrypted_item_name] = $item_data;
                                unset($data['STATISTICS'][$field][$period][
                                    $item_name]);
                            }
                        }
                        $i++;
                    }
                }
            }
        }
    }
    /**
     * importDiscussions used to import group discussion thread from another
     * grouping or bulletin site that has the ability to show the group as rss
     * or atom. Examples of such site are: phpBB, google groups, phorum
     * @param int $group_id id of group that thread post data will be imported
     *     into
     * @param int $user_id id of person doing the importing (should be owner of
     *     group)
     * @param string $feed_data an rss or atom feed containing forum/group posts
     */
    public function importDiscussions($group_id, $user_id, $feed_data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $page = preg_replace('@<(/?)(\w+\s*)\:@u', '<$1', $feed_data);
        $page = preg_replace("@<link@", "<slink", $page);
        $page = preg_replace("@</link@", "</slink", $page);
        $page = preg_replace("@pubDate@i", "pubdate", $page);
        $page = preg_replace("@&lt;@", "<", $page);
        $page = preg_replace("@&gt;@", ">", $page);
        $page = preg_replace("@<br(\s[^>]*)*\/?>@i", "[br]", $page);
        $page = preg_replace("@<hr(\s[^>]*)*\/?>@i", "[hr]", $page);
        $page = preg_replace("@<\/?i(\s[^>]*)*>@", "''", $page);
        $page = preg_replace("@<\/?b(\s[^>]*)*>@",  "'''", $page);
        $page = preg_replace("@<\/?u(\s[^>]*)*>@",  "'''", $page);
        $page = preg_replace("@<\/?tt(\s[^>]*)*>@",  "'''", $page);
        $page = preg_replace("@<p(\s[^>]*)*>@", "\n\n", $page);
        $page = preg_replace("@<\/p(\s[^>]*)*>@", "\n", $page);
        $page = preg_replace('@<\/?(o|u)l(\s[^>]*)*>@', "\n\n", $page);
        $page = preg_replace("@<li(\s[^>]*)*>@", "*", $page);
        $page = preg_replace("@<\/li(\s[^>]*)*>@", "\n", $page);
        $page = preg_replace("@<!\[CDATA\[(.+?)\]\]>@s", '$1', $page);
        $dom = L\getDomFromString($page);
        $rss_elements = ["title" => "title",
            "description" => "description", "link" =>"slink",
            "author" => ["author", "creator"],
            "guid" => "guid", "pubdate" => "pubdate"];
        $nodes = $dom->getElementsByTagName('item');
        if ($nodes->length == 0) {
            // maybe we're dealing with atom rather than rss
            $nodes = $dom->getElementsByTagName('entry');
            $rss_elements = [
                "title" => "title", "description" => ["summary", "content"],
                "link" => "slink", "guid" => "id",
                "author" => ["author", "creator"],
                "pubdate" => "updated"];
        }
        $num_added = 0;
        $num_seen = 0;
        $items = [];
        $feed_types = ['phpbb', 'googlegroup', 'phorum'];
        $feed_type = 'unknown';
        $i = 0;
        foreach ($nodes as $node) {
            $item = [];
            foreach ($rss_elements as $db_element => $feed_element) {
                if (!is_array($feed_element)) {
                    $feed_element = [$feed_element];
                }
                foreach ($feed_element as $tag_name) {
                    $tag_node = $node->getElementsByTagName(
                            $tag_name)->item(0);
                    $element_text = (is_object($tag_node)) ?
                        $tag_node->nodeValue: "";
                    if ($element_text) {
                        break;
                    }
                }
                if ($db_element == "link" && $tag_node && $element_text == "") {
                    $element_text = $tag_node->getAttribute("href");
                }
                $element_text = htmlentities(strip_tags($element_text));
                $element_text = preg_replace('/\[br\]/', "<br>", $element_text);
                $element_text = preg_replace('/\[hr\]/', "<hr>", $element_text);
                $item[$db_element] = $element_text;
            }
            if ($feed_type == 'unknown') {
                if (stripos($item['link'], 'viewtopic.php') !== false) {
                    $feed_type = 'phpbb';
                } else if (stripos($item['link'],'groups.google.com')
                    !==false) {
                    $feed_type = 'googlegroup';
                } else if (stripos($item['link'], 'read.php') !== false) {
                    $feed_type = 'phorum';
                }
            }
            switch ($feed_type) {
                case 'phpbb':
                    if (@preg_match('/t\=(\d+)/', $item['link'], $match)
                        !== false) {
                        $item['thread'] = $match[1];
                    }
                    if (($pos = strrpos($item['description'], 'Statistics:'))
                        !== false) {
                        $item['description'] = substr($item['description'], 0,
                            $pos);
                    }
                    if (($pos = strrpos($item['title'], '&bull;'))
                        !== false) {
                        $item['title'] = trim(substr($item['title'], $pos + 6));
                    }
                    break;
                case 'googlegroup':
                    if (@preg_match('@/d/msg/.*/(.*)/@', $item['link'], $match)
                        !== false) {
                        $item['thread'] = $match[1];
                    }
                    break;
                case 'phorum':
                    if (@preg_match('@read\.php\?.*\,(.*)\,@', $item['link'],
                        $match) !== false) {
                        $item['thread'] = $match[1];
                    }
                    break;
                default:
                    $item['thread'] = $i;
            }
            $i++;
            $pos = (strtotime($item['pubdate'], 0)) ?
                strtotime($item['pubdate'], 0) : $i;
            $items[$pos] = $item;
        }
        ksort($items);
        $threads = [];
        foreach ($items as $item) {
            $parent_id = 0;
            if (!empty($item['thread']) &&
                !empty($threads[$item['thread']])) {
                $parent_id = $threads[$item['thread']];
                $item['title'] = preg_replace("/^Re\:/", "--",
                    trim($item['title']), 1);
            }
            $post_prefix = "";
            $post_user_id = $group_model->getUserId($item['author']);
            if (!$post_user_id) {
                $post_user_id = $user_id;
                $post_prefix .= "'''" .
                    tl('social_component_originally_posted', $item['author']) .
                    "'''\n\n";
            }
            if (!$timestamp = strtotime($item['pubdate'], 0)) {
                $timestamp = time();
                $post_prefix .= "'''" .
                    tl('social_component_originally_dated', $item['pubdate']) .
                    "'''\n\n";
            }
            $thread_id = $this->addGroupItemWithinLimits(
                $parent_id, $group_id, $post_user_id, $item['title'],
                $parent->clean($post_prefix . $item['description'], "string"),
                C\STANDARD_GROUP_ITEM, $timestamp);
            if ($parent_id == 0) {
                $threads[$item['thread']] = $thread_id;
            }
        }
    }
    /**
     * addGroup used to add a group to a user's list of group or to request
     * membership in a group if the group is By Request or Public Request
     * @param array &$data field variables to be drawn to view, we modify the
     *     SCRIPT component of this with a message regarding success of not of
     *     add attempt.
     * @param int $add_id group id to be added
     * @param int $register the registration type of the group
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    public function addGroup(&$data, $add_id, $register)
    {
        $parent = $this->parent;
        $group_model = $parent->model('group');
        $credit_model = $parent->model('credit');
        $user_id = $_SESSION['USER_ID'];
        $join_type = (($register == C\REQUEST_JOIN ||
            $register == C\PUBLIC_BROWSE_REQUEST_JOIN) &&
            $_SESSION['USER_ID'] != C\ROOT_ID) ?
            C\INACTIVE_STATUS : C\ACTIVE_STATUS;
            // if register fee or anyone can join will be active_status
        if ($register >= C\LOW_JOIN_FEE && in_array(C\p('MONETIZATION_TYPE'),
            ['group_fees','fees_and_keywords'])) {
            $balance = $credit_model->getCreditBalance($user_id);
            if ($balance - $register < 0) {
                return $parent->redirectWithMessage(
                    tl('social_component_buy_more_credits_join'));
            }
            $group_name = $group_model->getGroupName($add_id);
            $strings_to_translate_for_model =
                [tl('social_component_join_group_fee')];
            $credit_model->updateCredits($user_id, -$register,
                'social_component_join_group_fee');
        }
        $this->addUserGroupWithinLimits($user_id, $add_id, $join_type);
        if ($join_type == C\ACTIVE_STATUS) {
            return $parent->redirectWithMessage(tl('social_component_joined'),
                ['browse', 'start_row', 'end_row', 'num_show']);
        }
        // if account needs to be activated email owner
        $group_info = $group_model->getGroupById($add_id,
            C\ROOT_ID);
        $user_model = $parent->model("user");
        $owner_info = $user_model->getUser(
            $group_info['OWNER']);
        $server = new SmtpClient(C\p('MAIL_SENDER'), C\p('MAIL_SERVER'),
            C\p('MAIL_SERVERPORT'), C\p('MAIL_USERNAME'), C\p('MAIL_PASSWORD'),
            C\p('MAIL_SECURITY'));
        $subject = tl('social_component_activate_group',
            $group_info['GROUP_NAME']);
        $current_username = $user_model->getUserName(
            $_SESSION['USER_ID']);
        $edit_user_url = C\p('NAME_SERVER') . "?c=admin&a=manageGroups".
            "&arg=groupsettings&group_id=$add_id&visible_users=true".
            "&user_filter=$current_username&preserve=true";
        $body = tl('social_component_activate_body',
            $current_username,
            $group_info['GROUP_NAME'])."\n".
            $edit_user_url . "\n\n".
            tl('social_component_notify_closing')."\n".
            tl('social_component_notify_signature');
        $message = tl(
            'social_component_notify_salutation',
            $owner_info['USER_NAME'])."\n\n";
        $message .= $body;
        $server->send($subject, C\p('MAIL_SENDER'),
            $owner_info['EMAIL'], $message);
        return $parent->redirectWithMessage(
            tl('social_component_group_request_join'),
            ['browse', 'start_row', 'end_row', 'num_show']);
    }

    /**
     * getGroupUsersData uses $_REQUEST and $user_id to look up all the users
     * that a group has to subject to $_REQUEST['user_limit'] and
     * $_REQUEST['user_filter']. Information about these roles is added as
     * fields to $data[NUM_USERS_GROUP'] and $data['GROUP_USERS']
     * @param array &$data data for the manageGroups view.
     * @param int $group_id group to look up users for
     */
    public function getGroupUsersData(&$data, $group_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $data['visible_users'] = $_REQUEST['visible_users'] ?? 'false';
        $data['USER_SORTS'] = (empty($_REQUEST['user_sorts'])) ? [] :
            json_decode(urldecode($_REQUEST['user_sorts']), true);
        if ($data['USER_SORTS'] === null) {
            $data['USER_SORTS'] = json_decode(html_entity_decode(
                urldecode($_REQUEST['user_sorts'])), true);
            $data['USER_SORTS'] = ($data['USER_SORTS']) ? $data['USER_SORTS'] :
                [];
        }
        if ($data['visible_users'] == 'false') {
            unset($_REQUEST['user_filter']);
            unset($_REQUEST['user_limit']);
        }
        if (isset($_REQUEST['user_filter'])) {
            $user_filter = substr($parent->clean(
                $_REQUEST['user_filter'], 'string'), 0, C\NAME_LEN);
        } else {
            $user_filter = "";
        }
        $data['USER_FILTER'] = $user_filter;
        $num_users =  $group_model->countGroupUsers($group_id, $user_filter);
        if (C\p('DIFFERENTIAL_PRIVACY')) {
            $data['NUM_USERS_GROUP'] = $parent->addDifferentialPrivacy(
                $num_users);
        } else {
            $data['NUM_USERS_GROUP'] = $num_users;
        }
        if (isset($_REQUEST['group_limit'])) {
            $group_limit = min($parent->clean(
                $_REQUEST['group_limit'], 'int'), $num_users);
            $group_limit = max($group_limit, 0);
        } else {
            $group_limit = 0;
        }
        $data['GROUP_LIMIT'] = $group_limit;
        $data['GROUP_USERS'] =
            $group_model->getGroupUsers($group_id, $user_filter,
            $data['USER_SORTS'], $group_limit);
    }
    /**
     * updateGroup used by $this->manageGroups to check and clean $_REQUEST
     * variables related to groups, to check that a user has the correct
     * permissions if the current group is to be modified, and if so, to call
     * model to handle the update
     * @param array &$data used to add any information messages for the view
     *     about changes or non-changes to the model
     * @param array &$group current group which might be altered
     * @param array $update_fields which fields in the current group might be
     *     changed. Elements of this array are triples, the name of the group
     *     field, name of the request field to use for data, and an array of
     *     allowed values for the field
     * @return string informational message describing the result of the update
     *     (e.g. permission failure or fields successfully changed), or an empty
     *     string when nothing was changed
     */
    public function updateGroup(&$data, &$group, $update_fields)
    {
        $parent = $this->parent;
        $changed = false;
        if (!isset($group["OWNER_ID"]) ||
            ($group["OWNER_ID"] != $_SESSION['USER_ID'] &&
            $_SESSION['USER_ID'] != C\ROOT_ID)) {
            return tl('social_component_no_permission');
        }
        $group_id = $group["GROUP_ID"];
        $return_value = "";
        foreach ($update_fields as $row) {
            list($request_field, $group_field, $check_field) = $row;
            if (isset($_REQUEST[$request_field]) &&
                $check_field == "valid_user") {
                $new_owner_name = substr(
                    $parent->clean($_REQUEST['owner'],
                    'string'), 0, C\NAME_LEN);
                $new_owner = $parent->model("user")->getUser(
                    $new_owner_name);
                if (!isset($new_owner['USER_ID']) ) {
                    return tl('social_component_not_a_user');
                }
                if (!$parent->model("group")->checkUserGroup(
                    $new_owner['USER_ID'], $group_id)) {
                    return tl('social_component_not_in_group');
                }
                if ($group["OWNER_ID"] != $new_owner['USER_ID']) {
                    $group["OWNER_ID"] = $new_owner['USER_ID'];
                    $changed = true;
                    $return_value =
                        tl('social_component_owner_updated');
                }
            } else if (isset($_REQUEST[$request_field]) &&
                in_array($_REQUEST[$request_field],
                    array_keys($data[$check_field]))) {
                if ($group[$group_field] != $_REQUEST[$request_field]) {
                    $group[$group_field] =
                        $_REQUEST[$request_field];
                    $changed = true;
                    $return_value =
                        tl('social_component_group_updated');
                }
            } else if (!empty($_REQUEST[$request_field]) &&
                is_int($_REQUEST[$request_field])) {
                $return_value =
                    tl('social_component_unknown_access');
            }
        }
        if ($changed) {
            $parent->model("group")->updateGroup($group);
        }
        return $return_value;
    }

    /**
     * getRequestedBots determines a list of posts that might need to reply to a
     * post in a group
     * @param int $group_id get chat bots following this group
     * @param string $description post message to see if called any bots by
     *     using a phrase like: @bot_name some request
     * @return array [array of bots referred to in post, array of post portions
     *     for each robot]
     */
    protected function getRequestedBots($group_id, $description)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $bot_followers = $group_model->getGroupBots($group_id);
        $bots = [];
        $bots_called = [];
        $post_parts = [];
        foreach ($bot_followers as $bot_follower) {
            $bots[] = $bot_follower['USER_NAME'];
        }
        if (preg_match_all('/(?<!\w)@(\w+)\s([^@]*)/si', $description,
            $matches)) {
            foreach ($matches[1] as $match) {
                $match = mb_strtolower($match);
                $index = array_search($match, $bots);
                if ($index !== false) {
                    $bots_called[] = $bot_followers[$index];
                } else {
                    $bots_called[] = null;
                }
            }
            $post_parts = $matches[2];
        }
        return [$bots_called, $post_parts];
    }
    /**
     * addAnyBotResponses this follows up to the thread post $thread_id to
     * $group_id any response that $bots following this group might have
     * @param int $thread_id id of the thread post to follow up
     * @param int $group_id of group thread post was posted to
     * @param array $bots list of chat bot users following group
     * @param string $title title of thread post to follow up
     * @param array $posts for each bot the contents of message applicable to
     *     that bot
     */
    protected function addAnyBotResponses($thread_id, $group_id, $bots, $title,
        $posts)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $num_bots = count($bots);
        $sites = [];
        $post_data = [];
        $time = time();
        $user_id = (empty($_SESSION['USER_ID'])) ? C\PUBLIC_USER_ID:
            $_SESSION['USER_ID'];
        $user_name = (empty($_SESSION['USER_NAME'])) ? "PUBLIC" :
            $_SESSION['USER_NAME'];
        if (empty($_SESSION["CHAT_BOT_STATES"])) {
            $_SESSION["CHAT_BOT_STATES"] = [];
        }
        for ($i = 0; $i < $num_bots; $i++) {
            if (empty($bots[$i]['USER_ID'])) {
                continue;
            }
            $bot_id = $bots[$i]['USER_ID'];
            if (empty($_SESSION["CHAT_BOT_STATES"][$bot_id])) {
                $_SESSION["CHAT_BOT_STATES"][$bot_id] = "0";
            }
            $bots[$i]['PATTERN'] =
                $this->computeBotPattern($bot_id, $posts[$i]);
            if (!empty($bots[$i]['PATTERN'])) {
                $bots[$i]['PATTERN']['VARS']['REMOTE_MESSAGE'] =
                    $this->interpolateBotVariables(
                    $bots[$i]['PATTERN']['REMOTE_MESSAGE'],
                    $bots[$i]['PATTERN']['VARS']);
            }
            if (empty($bots[$i]['PATTERN']['VARS']['REMOTE_MESSAGE'])) {
                $sites[$i] = [];
            } else {
                $sites[$i][CrawlConstants::URL] = $bots[$i]['CALLBACK_URL'];
                $post_data[$i] = "remote_message=".
                    urlencode($bots[$i]['PATTERN']['VARS']['REMOTE_MESSAGE']) .
                    "&post=" . urlencode($posts[$i]) . "&bot_token=" .
                    hash("sha256", $bots[$i]['BOT_TOKEN'] .
                        $time . $posts[$i]) . "*" . $time .
                    "&bot_name=" . $bots[$i]['USER_NAME'];
            }
        }
        $outputs = [];
        if (count($sites) > 0) {
            $outputs = FetchUrl::getPages($sites, false, 0, null,
                self::URL, self::PAGE, true, $post_data);
        }
        for ($i = 0;  $i < $num_bots; $i++) {
            if (!empty($bots[$i]['PATTERN']) &&
                isset($outputs[$i][self::PAGE]) ) {
                $bots[$i]['PATTERN']['VARS']['REMOTE_RESPONSE'] =
                    $outputs[$i][self::PAGE];
            }
        }
        foreach ($bots as $bot) {
            if (empty($bot['PATTERN'])) {
                continue;
            }
            $bot_id = $bot['USER_ID'];
            $result_state = $this->interpolateBotVariables(
                $bot['PATTERN']['RESULT_STATE'],
                $bot['PATTERN']['VARS']);
            $_SESSION["CHAT_BOT_STATES"][$bot_id] = (empty($result_state)) ?
                "0" : $result_state;
            $bot['PATTERN']['VARS']['RESULT_STATE'] = $result_state;
            $response = $this->interpolateBotVariables(
                $bot['PATTERN']['RESPONSE'],
                $bot['PATTERN']['VARS']);
            if (!empty($response)) {
                $this->addGroupItemWithinLimits($thread_id,
                    $group_id, $bot_id, $title, $response);
            }
        }
    }
    /**
     * computeBotPattern determines which, if any, chat bot patterns of chat bot
     * $bot_id are applicable to the post $post given the current state of the
     * chat bot for the user who made $post.
     * @param int $bot_id of chat bot to look for applicable pattern
     * @param string $post messages to compare against pattern request
     *     expressions
     * @return array $pattern first pattern that matches. Its ['VARS'] field
     *     will contain any binding values that were made to make the match
     */
    private function computeBotPattern($bot_id, $post)
    {
        $parent = $this->parent;
        $bot_model = $parent->model("bot");
        $total = 0;
        $patterns = $bot_model->getRows(0, C\MAX_BOT_PATTERNS,
            $total, [], [$bot_id]);
        if (empty($patterns)) {
            return [];
        }
        $post = preg_replace("/" . C\PUNCT . "/", " ", $post);
        $post = trim(preg_replace("/\s+/mu", " ", $post));
        foreach ($patterns as $pattern) {
            $request = $pattern['REQUEST'];
            $num_vars = preg_match_all('/\$(\w+)/', $request, $var_matches);
            $request = preg_replace('/\$\w+/', "dzqqzd", $request);
            $request = preg_replace("/" . C\PUNCT . "/", " ", $request);
            $request = trim(preg_replace('/\s+/mu', " ", $request));
            $request = preg_quote($request, "/");
            $request = preg_replace('/dzqqzd/', "(.+)", $request);
            $num_matches = preg_match("/$request/iu", $post, $matches);
            if ($num_matches > 0) {
                array_shift($matches);
                $bot_variables = array_combine($var_matches[1], $matches);
                if (!empty($_SESSION['USER_NAME'])) {
                    $bot_variables['USER_NAME'] = $_SESSION['USER_NAME'];
                }
                $state = $this->interpolateBotVariables(
                    $pattern['TRIGGER_STATE'], $bot_variables);
                if ($_SESSION["CHAT_BOT_STATES"][$bot_id] == $state) {
                    $pattern['VARS'] = $bot_variables;
                    return $pattern;
                }
            }
        }
        return [];
    }
    /**
     * interpolateBotVariables given a string $to_interpolate with variables in
     * it (strings of word characters beginning with a $) and given an array of
     * variable => value, replaces the variables in $to_inpolate with their
     * corresponding value, returning the resulting string
     * @param string $to_interpolate string to replace variables in
     * @param array $bot_variables sequence of variable => value pairs to
     *     replace in string.
     * @return string $to_interpolate after substitutions have been made
     */
    private function interpolateBotVariables($to_interpolate, $bot_variables)
    {
        foreach ($bot_variables as $var => $value) {
            $pattern = '/\$' . preg_quote($var, "/") . '/u';
            $to_interpolate = preg_replace($pattern, $value, $to_interpolate);
        }
        return $to_interpolate;
    }
    /**
     * initializeFeedItems used to compute set up a list of feed items to be
     * displayed by the groupFeeds activity
     * @param array &$data associative array of values to be echoed by the view
     *     this method might add to INCLUDE_SCRIPT formatting scripts such as
     *     for math which might be used to help draw feed items
     * @param array $pages contains feed items corresponding to first join dates
     *     to various groups. Other feed items will be added to this array
     * @param int $user_id id of user requesting thread info
     * @param array $search_array associative array used to determine where
     *     clause of what threads, groups, or user posts to get feed items for
     * @param int $for_group if this value is set it is a assumed that
     *     group_items are being returned for only one group and that they
     *     should be grouped by thread
     * @param string $sort either ksort or krsort to specify final sort
     *     direction of feed items
     * @param int $limit index of first feed item out of all applicable items to
     *     display
     * @param int $results_per_page number of feed items to display feed data
     *     for
     */
    protected function initializeFeedItems(&$data, $pages, $user_id,
        $search_array, $for_group, $sort, &$limit, $results_per_page)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $impression_model = $parent->model("impression");
        $user_model = $parent->model("user");
        $item_count = $feed_model->getGroupItemCount($search_array, $user_id,
            $for_group);
        $updatable = false;
        if (!empty($data["JUST_THREAD"]) && $data["JUST_THREAD"] >= 0
            && empty($data["GROUP_ID"])) {
            $display_message = $_SESSION['DISPLAY_MESSAGE'] ?? "";
            $is_wiki = !empty($data["HEAD"]['page_type']) &&
                $data["HEAD"]['page_type'] == 'page_and_feedback';
            if (!$is_wiki &&
                $display_message == tl('social_component_comment_added')) {
                $limit = floor($item_count / $results_per_page) *
                    $results_per_page;
            }
            if ($limit > $item_count - $results_per_page) {
                $updatable = true;
            }
        }
        $group_items = $feed_model->getGroupItems(0,
            $limit + $results_per_page, $search_array, $user_id, $for_group);
        $recent_found = false;
        $time = time();
        $j = 0;
        $parser = new WikiParser("", true);
        $locale_tag = L\getLocaleTag();
        $page = false;
        $math = false;
        $csrf_token = C\p('CSRF_TOKEN') . "=" .
            $parent->generateCSRFToken(
            $user_id);
        if ($for_group) {
            $render_engine = $group_model->getRenderEngine($for_group);
            $data['RENDER_ENGINE'] = ($render_engine == C\MARKDOWN_ENGINE) ?
                "markdown" : "mediawiki";
        }
        foreach ($group_items as $item) {
            $page = $item;
            if (C\p('DIFFERENTIAL_PRIVACY') && !empty($page['NUM_VIEWS'])) {
                /* Recalculate fuzzy view only if NUM_VIEWS
                   has been updated since last calculation
                 */
                if (empty($page['TMP_NUM_VIEWS']) ||
                    ($page['NUM_VIEWS'] != $page['TMP_NUM_VIEWS'])) {
                    // fuzzify the number of views to add privacy
                    $fuzzy_views =
                        $parent->addDifferentialPrivacy($page['NUM_VIEWS']);
                    $impression_model->updatePrivacyViews($page['ID'],
                        $page['NUM_VIEWS'], $fuzzy_views);
                    $page['NUM_VIEWS'] = $fuzzy_views;
                } else {
                    $page['NUM_VIEWS'] = $page['FUZZY_NUM_VIEWS'];
                }
            }
            if (empty($page['NUM_VIEWS'])) {
                $page['NUM_VIEWS'] = 0;
            }
            $page['USER_ICON'] = $user_model->getUserIconUrl($page['USER_ID']);
            $page[self::TITLE] = $page['TITLE'];
            //functionality for moderation group
            if($page['GROUP_ID'] == C\MODERATION_GROUP_ID
                && $page['FLAG'] != C\MODERATION_GENERAL) {
                $parent_group_id = $group_model->getParentGroupId($page['ID']);
                $page['PARENT_ITEM_ID'] =
                    $feed_model->getParentIdOfParentPost($page['ID']);
                $flag_group_name = $group_model->getGroupName($parent_group_id);
                $page['IS_MEMBER'] = false;
                if ($group_model->checkUserGroup($user_id, $parent_group_id)) {
                    $page['IS_MEMBER'] = true;
                }
                $page[self::TITLE] =  tl('social_component_flagged_post',
                    $flag_group_name, $page['TITLE']);
            }
            unset($page['TITLE']);
            $description = $page['DESCRIPTION'];
            //start code for sharing crawl mixes
            preg_match_all("/\[\[([^\:\n]+)\:mix(\d+)\]\]/", $description,
                $matches);
            $num_matches = count($matches[0]);
            for ($i = 0; $i < $num_matches; $i++) {
                $match = preg_quote($matches[0][$i], "@");
                $match = str_replace("@","\@", $match);
                $replace = "<a href='?c=admin&amp;a=mixCrawls" .
                    "&amp;arg=importmix&amp;".C\p('CSRF_TOKEN')."=".
                    $parent->generateCSRFToken($user_id).
                    "&amp;timestamp={$matches[2][$i]}'>".
                    $matches[1][$i]."</a>";
                $description = preg_replace("@".$match."@u", $replace,
                    $description);
                $page["NO_EDIT"] = true;
            }
            //end code for sharing crawl mixes
            $render_engine = $group_model->getRenderEngine($page['GROUP_ID']);
            $data['RENDER_ENGINE'] = ($render_engine == C\MARKDOWN_ENGINE) ?
                "markdown" : "mediawiki";
            $page[self::DESCRIPTION] = $parser->parse($description,
                render_engine: $render_engine);
            $page[self::DESCRIPTION] =
                $wiki_model->insertResourcesParsePage($item['GROUP_ID'],
                 "post" . $item['ID'], $locale_tag, $page[self::DESCRIPTION]);
            /* A resource named as a path carries the token as a path
               part rather than as a query, so that form is replaced
               first with the bare token. Replacing only the query form
               left "token=..." sitting in the path, which a browser
               reads as an invalid address and a recording would not
               play. */
            $bare_token = substr($csrf_token,
                strlen(C\p('CSRF_TOKEN')) + 1);
            $page[self::DESCRIPTION] = preg_replace(
                '/\/\[{rtoken}\]\//',
                ($bare_token === "") ? "/-/" : "/$bare_token/",
                $page[self::DESCRIPTION]);
            $page[self::DESCRIPTION] = preg_replace('/\[{rtoken}\]/',
                $csrf_token, $page[self::DESCRIPTION]);
            if (!$math && str_contains($page[self::DESCRIPTION], "`")) {
                $math = true;
                if (!isset($data["INCLUDE_SCRIPTS"])) {
                    $data["INCLUDE_SCRIPTS"] = [];
                }
                $data["INCLUDE_SCRIPTS"][] = "math";
            }
            unset($page['DESCRIPTION']);
            $page['OLD_DESCRIPTION'] = $description;
            $page[self::SOURCE_NAME] = $page['GROUP_NAME'];
            unset($page['GROUP_NAME']);
            if ($item['OWNER_ID'] == $user_id || $user_id == C\ROOT_ID) {
                $page['MEMBER_ACCESS'] = C\GROUP_READ_WIKI;
            }
            if ($updatable &&
                !$recent_found && !$math && $time - $item["PUBDATE"] <
                5 * C\ONE_MINUTE) {
                $recent_found = true;
                $data['SCRIPT'] .= 'window.onload = doUpdate;';
            }
            $pages[$item["PUBDATE"] . sprintf("%04d", $j)] = $page;
            $j++;
        }
        if ($pages) {
            $sort($pages);
            $pages = array_slice($pages, $limit, $results_per_page);
        }
        return [$item_count, $pages];
    }

    /**
     * overUserGroupLimit says whether adding items would pass a user's role-
     * group limit    /** Says whether adding items would pass a user's role-
     * group limit for one ROLE_LIMITS column. A -1 cap means unlimited.
     * @param int $user_id whose role-group limits apply
     * @param string $column ROLE_LIMITS column such as MAX_GROUPS_OWNED
     * @param int $current how many already exist
     * @param int $add how many are being added, default 1
     * @return bool true when $current + $add would pass the cap
     */
    private function overUserGroupLimit($user_id, $column, $current,
        $add = 1)
    {
        $limits = $this->parent->model("role")->getUserGroupLimits(
            $user_id);
        $cap = $limits[$column] ?? -1;
        return $cap != -1 && $current + $add > $cap;
    }
    /**
     * overGroupLimit says whether adding items would pass a group owner's role-
     * group limit for one ROLE_LIMITS column. The owner's roles size the group,
     * so a member growing someone else's group is still held to that owner's
     * cap.
     * @param int $group_id group whose owner's limits apply
     * @param string $column ROLE_LIMITS column such as MAX_GROUP_MEMBERS
     * @param int $current how many already exist
     * @param int $add how many are being added, default 1
     * @return bool true when $current + $add would pass the cap
     */
    protected function overGroupLimit($group_id, $column, $current, $add = 1)
    {
        $group = $this->parent->model("group")->getGroupById($group_id,
            C\ROOT_ID);
        if (empty($group)) {
            return false;
        }
        $owner_id = $group['OWNER'] ?? ($group['OWNER_ID'] ?? 0);
        return $this->overUserGroupLimit($owner_id, $column, $current,
            $add);
    }
    /**
     * addGroupItemWithinLimits adds a group feed item after checking the
     * owner's thread and post caps. A new thread (parent 0) is held to
     * MAX_GROUP_THREADS and a reply to the thread's MAX_THREAD_POSTS; past the
     * cap the request is redirected with a message and no item is added.
     * Arguments and the return value otherwise match GroupModel::addGroupItem.
     * @param int $parent_id thread id, or 0 to start a new thread
     * @param int $group_id group the item belongs to
     * @param int $user_id who is posting
     * @param string $title item title
     * @param string $description item body
     * @param int $type kind of group item
     * @param int $post_time post timestamp
     * @param string $url url associated with the item
     * @param int $edit_time last edit timestamp
     * @param int $ups up votes
     * @param int $downs down votes
     * @param int $flag moderation flag
     * @param int $input_timestamp impression timestamp seed
     * @return mixed id of the added item, or a redirect when over a cap
     */
    protected function addGroupItemWithinLimits($parent_id, $group_id,
        $user_id, $title, $description, $type = C\STANDARD_GROUP_ITEM,
        $post_time = 0, $url = "", $edit_time = 0, $ups = 0, $downs = 0,
        $flag = 0, $input_timestamp = -1)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        if ($parent_id == 0) {
            $over = $this->overGroupLimit($group_id, 'MAX_GROUP_THREADS',
                $feed_model->countGroupThreads($group_id));
            $message = tl('social_component_group_threads_full');
        } else {
            $over = $this->overGroupLimit($group_id, 'MAX_THREAD_POSTS',
                $feed_model->countThreadPosts($parent_id));
            $message = tl('social_component_thread_posts_full');
        }
        if ($over) {
            return $parent->redirectWithMessage($message);
        }
        return $feed_model->addGroupItem($parent_id, $group_id, $user_id,
            $title, $description, $type, $post_time, $url, $edit_time,
            $ups, $downs, $flag, $input_timestamp);
    }
    /**
     * addUserGroupWithinLimits adds a user to a group after checking the
     * owner's member cap. Past MAX_GROUP_MEMBERS the request is redirected with
     * a message and no membership is added. Arguments match
     * GroupModel::addUserGroup.
     * @param int $user_id user to add to the group
     * @param int $group_id group to add the user to
     * @param int $status membership status to grant
     * @return mixed result of the add, or a redirect when over the cap
     */
    protected function addUserGroupWithinLimits($user_id, $group_id,
        $status = C\ACTIVE_STATUS)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if ($this->overGroupLimit($group_id, 'MAX_GROUP_MEMBERS',
            $group_model->countGroupUsers($group_id))) {
            return $parent->redirectWithMessage(
                tl('social_component_group_members_full'));
        }
        return $group_model->addUserGroup($user_id, $group_id, $status);
    }
    /**
     * handleResourceUploads used to handle file uploads either to message posts
     * or wiki pages
     * @param string $group_id the group the message or wiki page is associated
     *     with
     * @param string $store_id the id of the message post or wiki page
     * @param string $sub_path used to specify sub-folder of default resource
     *     folder to copy to
     * @return string one of the self::UPLOAD_* status constants:
     *     UPLOAD_NO_FILES if nothing was submitted, UPLOAD_FAILED if a file
     *     copy failed, or UPLOAD_SUCCESS on completion
     */
    public function handleResourceUploads($group_id, $store_id, $sub_path = "")
    {
        if (!isset($_FILES) || !is_array($_FILES)) {
            return self::UPLOAD_NO_FILES;
        }
        $keys = array_keys($_FILES);
        if (!isset($keys[0])) {
            return self::UPLOAD_NO_FILES;
        }
        $upload_field = $keys[0];
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        if (!isset($_FILES[$upload_field]['name'])) {
            return self::UPLOAD_NO_FILES;
        }
        $upload_parts = ['name', 'full_path', 'type', 'tmp_name', 'data'];
        $is_file_array = false;
        $num_files = 1;
        if (is_array($_FILES[$upload_field]['name'])) {
            $num_files =
                count($_FILES[$upload_field]['name']);
            $is_file_array = true;
        }
        $files = [];
        $upload_okay = true;
        for ($i = 0; $i < $num_files; $i ++) {
            foreach ($upload_parts as $part) {
                $file_part = ($is_file_array && isset(
                    $_FILES[$upload_field][$part][$i])) ?
                    $_FILES[$upload_field][$part][$i] :
                    ((!$is_file_array && isset(
                    $_FILES[$upload_field][$part])) ?
                    $_FILES[$upload_field][$part] :
                    false );
                if ($part == 'data') {
                    $files[$i][$part] = (empty($file_part) ) ? "" :
                        $file_part;
                    continue;
                }
                if ($file_part) {
                    $files[$i][$part] = $parent->clean(
                        $file_part, 'string');
                } else {
                    $upload_okay = false;
                    break 2;
                }
            }
        }
        if ($upload_okay) {
            $is_thread = strncmp($store_id, "post", 4) === 0;
            $resource_folders = $wiki_model->getGroupPageResourcesFolders(
                $group_id, $store_id);
            $resource_folder = (is_array($resource_folders)) ?
                ($resource_folders[0] ?? "") : "";
            $existing_files = (is_string($resource_folder) &&
                $resource_folder !== "" && is_dir($resource_folder)) ?
                glob($resource_folder . "/*") : [];
            if ($is_thread) {
                $over_resource = $this->overGroupLimit($group_id,
                    'MAX_THREAD_RESOURCES', count($existing_files),
                    $num_files);
                $resource_message =
                    tl('social_component_thread_resources_full');
            } else {
                $existing_bytes = 0;
                foreach ($existing_files as $existing_file) {
                    $existing_bytes += (is_file($existing_file)) ?
                        filesize($existing_file) : 0;
                }
                $incoming_bytes = 0;
                foreach ($files as $file) {
                    $incoming_bytes += (isset($file['tmp_name']) &&
                        is_file($file['tmp_name'])) ?
                        filesize($file['tmp_name']) : 0;
                }
                $over_resource = $this->overGroupLimit($group_id,
                    'MAX_PAGE_RESOURCE_MEMORY', $existing_bytes,
                    $incoming_bytes);
                $resource_message =
                    tl('social_component_page_resources_full');
            }
            if ($over_resource) {
                return $parent->redirectWithMessage($resource_message);
            }
            foreach ($files as $file) {
                $file_sub_path = $sub_path;
                /* A file uploaded from inside a dropped folder arrives
                   with its folder path in front of its name, for
                   example "photos/trip/beach.jpg". That path is read
                   from full_path rather than from name: PHP takes the
                   folders off name before a handler ever sees it, so
                   under a web server the folders were lost and every
                   file landed in one heap, while under the command line
                   server, which does no such thinning, they survived.
                   full_path is what both leave whole. Pull the folder
                   part off and fold it into the sub-path so those
                   folders get created, and keep just the bare name for
                   the file itself, so the file is written into the
                   folder rather than having the path repeated. */
                $client_path = $file['full_path'];
                $base_name = pathinfo($client_path, PATHINFO_BASENAME);
                $folder_part = trim(str_replace("\\", "/",
                    pathinfo($client_path, PATHINFO_DIRNAME)), "/");
                if ($folder_part !== "" && $folder_part !== ".") {
                    $file_sub_path = ($sub_path === "") ? $folder_part :
                        trim($sub_path, "/") . "/" . $folder_part;
                }
                $wiki_model->copyFileToGroupPageResource(
                    $file['tmp_name'], $base_name, $file['type'],
                    $group_id, $store_id, $file_sub_path, $file['data']);
            }
        }
        if (!$upload_okay) {
            return self::UPLOAD_FAILED;
        }
        return self::UPLOAD_SUCCESS;
    }

    /**
     * addActivityInfoToGroups adds to each group the view is about
     * to draw how busy it has been: how many threads and posts it
     * holds, and when it was last written in.
     *
     * @param array &$data the values the view will be given, whose
     *     list of groups is added to
     */
    public function addActivityInfoToGroups(&$data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $impression_model = $parent->model("impression");
        $num_shown = count($data['GROUPS']);
        $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
        for ($i = 0; $i < $num_shown; $i++) {
            $group = $data['GROUPS'][$i];
            $group_id = $group['GROUP_ID'];
            $item = $feed_model->getMostRecentGroupPost($group_id);
            $most_recent_views = $impression_model->mostRecentGroupViews(
                $user_id, [$group_id]);
            $group["MOST_RECENT_VIEW"] =
                $most_recent_views[$group_id] ?? 0;
            $group['NEW_POSTS'] =
                $feed_model->getGroupPostCount($group_id,
                $group["MOST_RECENT_VIEW"]);
            $group['NUM_POSTS'] = $feed_model->getGroupPostCount($group_id);
            $group['NUM_THREADS'] =
                $feed_model->getGroupThreadCount($group_id);
            $group['NUM_PAGES'] = $wiki_model->getGroupPageCount(
                $group['GROUP_ID']);
            $group["MEMBER_STATUS"] = $group_model->checkUserGroup(
                $user_id, $group_id);
            if (isset($item['TITLE'])) {
                $group["ITEM_TITLE"] = $item['TITLE'];
                $group["THREAD_ID"] = $item['PARENT_ID'];
            } else {
                $group["ITEM_TITLE"] = tl('social_component_no_posts_yet');
                $group["THREAD_ID"] = -1;
            }
            $data['GROUPS'][$i] = $group;
        }
        $data['NUM_SHOWN'] = $num_shown;
    }

    /**
     * initializeReadMode sets up view variables for wiki pages when in read
     * mode. If a user send a command to indicate a media resource on a media
     * list is not viewed, then also update session accordingly
     * @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
     */
    protected function initializeReadMode(&$data, $user_id, $group_id,
        $sub_path)
    {
        $parent = $this->parent;
        $feed_model = $parent->model("feed");
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $this->applyGroupAppearance($data, $parent->view($data['VIEW']),
            $data["GROUP"] ?? []);
        /* What an article is filed under is shown to a reader, so someone
           who wants more of the same has somewhere to go from the end of
           it. Only an article carries these, since only an article is
           filed. */
        if (($data["HEAD"]['page_type'] ?? "") == "news_article" &&
            !empty($data['PAGE_ID'])) {
            $data['PAGE_CATEGORIES'] =
                $wiki_model->getPageCategories($data['PAGE_ID']);
        }
        $this->checkAuthRequirement($data, $user_id);
        $member_not_editor = $group_model->checkUserGroup($user_id, $group_id,
            C\ACTIVE_STATUS);
        $editor = $group_model->checkUserGroup($user_id,
            $group_id, C\EDITOR_STATUS);
        $data["NOT_MEMBER"] = !$member_not_editor && !$editor;
        if (isset($data["HEAD"]['page_header']) &&
            $data["HEAD"]['page_type'] != 'presentation') {
            $page_header = $wiki_model->getPageInfoByName($group_id,
                $data["HEAD"]['page_header'],
                $data['CURRENT_LOCALE_TAG'], $data["MODE"]);
            if (isset($page_header['PAGE'])) {
                $header_parts =
                    explode(WikiParser::END_HEAD_VARS, $page_header['PAGE']);
            }
            $data["PAGE_HEADER"] = (isset($header_parts[1])) ?
                $header_parts[1] : ($page_header['PAGE'] ?? "");
        }
        if (($data["HEAD"]['page_type'] ?? "") == 'share' &&
            !empty($data['PAGE_ID'])) {
            $last_edit = $wiki_model->getPageHistoryList(
                $data['PAGE_ID'], 0, 1);
            if (!empty($last_edit)) {
                $data['LAST_EDITOR'] = $last_edit[2][0]["USER_NAME"];
                $data['LAST_EDIT_TIME'] = $last_edit[2][0]["PUBDATE"];
                $share_expires = $data["HEAD"]['share_expires'] ?? C\FOREVER;
                if ($share_expires != C\FOREVER) {
                    $expires_time = $data['LAST_EDIT_TIME'] + $share_expires;
                    if (time() > $expires_time) {
                        $data['PAGE'] = "";
                    }
                }
            }
        }
        if (isset($data["HEAD"]['page_footer']) &&
            $data["HEAD"]['page_type'] != 'presentation') {
            $page_footer = $wiki_model->getPageInfoByName($group_id,
                $data["HEAD"]['page_footer'], $data['CURRENT_LOCALE_TAG'],
                $data["MODE"]);
            if (isset($page_footer['PAGE'])) {
                $footer_parts =
                    explode(WikiParser::END_HEAD_VARS, $page_footer['PAGE']);
            }
            $data['PAGE_FOOTER'] = (isset($footer_parts[1])) ?
                $footer_parts[1] : ($page_footer['PAGE'] ?? "");
        }
        $data["INCLUDE_SCRIPTS"] ??= [];
        if (str_contains($data["PAGE"], "`")) {
            $data["INCLUDE_SCRIPTS"][] = "math";
        }
        if (str_contains($data["PAGE"], "canvas-360")) {
            $data["INCLUDE_SCRIPTS"] = array_merge($data["INCLUDE_SCRIPTS"],
                ["wglu-program", "vr-panorama", "vr-util"]);
            $data["SCRIPT"] .= ";var tl_elt = elt('tl'); tl_elt.enter_vr ='".
                tl('enter_vr') . "'; tl_elt.exit_vr = '".tl('exit_vr')."';";
        }
        if (preg_match("/\(\(resource(\-?[a-z]+)?\:(.+?)csv(.+?)\|(.+?)\)\)/ui",
            $data["PAGE"])) {
            if ($data['AUTHORIZED']) {
                $data["PAGE"] = $wiki_model->insertResourcesParsePage(
                    $group_id, $data["PAGE_ID"], $data['CURRENT_LOCALE_TAG'],
                    $data["PAGE"], "", "admin", true);
            } else {
                $data['PAGE'] =
                    "<h1 class='center-page warning'>{$last_match}</h1>";
            }
        }
        if (stripos($data["PAGE"], "chart_data") !== false) {
            if (!in_array("chart", $data["INCLUDE_SCRIPTS"])) {
                $data["INCLUDE_SCRIPTS"][] = "chart";
            }
            if (!str_contains($data["SCRIPT"], "new Chart")) {
                if ($_SERVER["MOBILE"]) {
                    $properties = ["width" => 340, "height" => 300,
                        "tick_font_size" => 8];
                } else {
                    $properties = ["width" => 700, "height" => 500];
                }
                $data['SCRIPT'] .= <<< 'EOD'
                for (var chart_elt in chart_data) {
                    var chart = new Chart(
                        'chart_' + chart_elt,
                        chart_data[chart_elt],
                        chart_config[chart_elt]);
                    chart.draw();
                }
EOD;
            }
        }
        if (str_contains($data["PAGE"], "[{proof-of-work}]")) {
            $parent->setupProofOfWorkViewData($data);
        }
        if (str_contains($data["PAGE"], "spreadsheet_data")) {
            if (!in_array("spreadsheet", $data["INCLUDE_SCRIPTS"])) {
                $data["INCLUDE_SCRIPTS"][] = "spreadsheet";
            }
            if (!str_contains($data["SCRIPT"], "new Spreadsheet")) {
                $data['SCRIPT'] .= <<< 'EOD'
                var spreadsheet = [];
                var i = 0;
                for (var spreadsheet_elt in spreadsheet_data) {
                    spreadsheet[i] = new Spreadsheet(
                        'spreadsheet_' + spreadsheet_elt,
                        spreadsheet_data[spreadsheet_elt],
                        spreadsheet_config[spreadsheet_elt]);
                    spreadsheet[i].draw();
                    i++;
                }
EOD;
            }
            $data['SPREADSHEET'] = true;
        }
        $witnesses = $this->witnessesNamedOnPage($data['PAGE']);
        if (!empty($witnesses)) {
            $this->initializeBallot($data, $user_id, $group_id, $sub_path,
                $witnesses);
        }
        if (preg_match("/\[{(share-edited-form|user-record-form|".
            "hash-record-form)}\]/", $data["PAGE"], $form_matches) ) {
            $is_shared = $form_matches[1] == 'share-edited-form';
            $active_field = ($is_shared) ? "" :
                (($form_matches[1] == 'user-record-form') ? "username" :
                "user_captcha_text");
            $default_folders = $wiki_model->getGroupPageResourcesFolders(
                $group_id, $data['PAGE_ID']);
            $csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
            if (file_exists($csv_filepath) &&
                ($fh = fopen($csv_filepath, "r")) !== false) {
                $csv_headers = fgetcsv($fh, escape: "\\");
                $i = 0;
                $key_column = -1;
                foreach ($csv_headers as $csv_field) {
                    if (!empty($csv_field) && $csv_field == $active_field) {
                        $key_column = $i;
                    }
                    $col_numbers[$csv_field] = $i;
                    $i++;
                }
                $key = "";
                $request_key = empty($_REQUEST[$active_field]) ? "" :
                    trim($parent->clean($_REQUEST[$active_field], "string"));
                if ($active_field == "username") {
                    $request_key = $_SESSION["USER_NAME"];
                }
                while ($csv_data = fgetcsv($fh, escape: "\\")) {
                    if ($key_column >= 0) {
                        $key = $csv_data[$key_column];
                    }
                    if ($is_shared || (!empty($request_key) &&
                        $request_key == $key)) {
                        foreach($csv_headers as $csv_field) {
                            $_REQUEST[$csv_field] = $csv_data[
                                $col_numbers[$csv_field]] ?? "";
                        }
                        break;
                    }
                }
                fclose($fh);
            }
            if ($is_shared) {
                $data["PAGE"] = preg_replace("/\[{share-edited-form}\]/", "",
                    $data["PAGE"]);
            } else if ($active_field == 'username') {
                $data["PAGE"] = preg_replace(
                    "/\[{user-record-form}\]/",
                    "<input type='hidden' name='username' " .
                    "value='[{username}]' ><input type='hidden' " .
                    "name='CSVFORM[username]' value='textfield' >",
                    $data["PAGE"]);
            } else if ($active_field == 'user_captcha_text') {
                $data["PAGE"] = preg_replace(
                    "/\[{hash-record-form}\]/","", $data["PAGE"]);
            }
        }
        /* Each list tag is filled in one pass over the page. This had
           been a loop that matched the page afresh each time, which never
           ended when what it wrote in could be matched again: a page
           listed under the category carrying such a tag in its own
           description was enough, and one request then held the whole
           site. A single pass visits each tag once and cannot meet its
           own writing. */
        /* A reader whose browser has scrolled to the end of a category's
           list asks for the next page of it on its own, and what comes
           back is that page of the list and nothing else. */
        if (!empty($_REQUEST['category_more'])) {
            $wanted = $parent->clean($_REQUEST['category_more'], "string");
            $said = "classes=|num=" .
                max(1, (int)$parent->clean($_REQUEST['category_num'] ?? 10,
                "int")) . "|page=" .
                max(1, (int)$parent->clean($_REQUEST['category_page'] ?? 1,
                "int")) . "|sort=" .
                $parent->clean($_REQUEST['category_sort'] ?? "", "string");
            $data["FRONT_PAGE_PLACE"] = $this->categoryListMarkup($parent,
                $group_model, $group_id, $data, $user_id, $wanted, $said);
            $data["GROUP_ID_FOR_PLACE"] = $group_id;
            $data[C\p('CSRF_TOKEN')] ??=
                $parent->generateCSRFToken($user_id);
            $parent->displayView("api", $data);
            \seekquarry\atto\webExit();
        }
        /* A place holding the names of a group's categories, each a way
           into the pages filed under it. */
        $names_match = "/\\[\\{category-names\\|([^\\}]*)\\}\\]/";
        $data["PAGE"] = preg_replace_callback($names_match,
            function ($matches) use ($parent, $group_model, $group_id,
                $data, $user_id) {
                return $this->categoryNamesMarkup($parent, $group_model,
                    $group_id, $data, $user_id, $matches[1]);
            }, $data["PAGE"]);
        $lead_match = "/\\[\\{lead-story\\|([^\\|]+)\\|([^\\|\\}]+)" .
            "\\|?([^\\}]*)\\}\\]/";
        $data["PAGE"] = preg_replace_callback($lead_match,
            function ($matches) use ($parent, $group_model, $group_id,
                $data, $user_id) {
                return $this->leadStoryMarkup($parent, $group_model,
                    $group_id, $data, $user_id, $matches[1], $matches[2],
                    (int)($matches[3] ?? 0));
            }, $data["PAGE"]);
        $category_match = "/\\[\\{category-list\\|([^\\|]+)\\|([^\\}]+)\\}\\]/";
        $data["PAGE"] = preg_replace_callback($category_match,
            function ($matches) use ($parent, $group_model, $group_id,
                $data, $user_id) {
                return $this->categoryListMarkup($parent, $group_model,
                    $group_id, $data, $user_id, $matches[1], $matches[2]);
            }, $data["PAGE"]);
        if (empty($data["HEAD"]['page_type'])) {
            return;
        }
        //handles template page types for read case
        if ($data["HEAD"]['page_type'][0] == 't' &&
            is_numeric(substr($data["HEAD"]['page_type'], 1))) {
            $templates = $group_model->getTemplateMap($group_id,
                $data['CURRENT_LOCALE_TAG']);
            if (empty($_REQUEST['n']) &&
                !empty($templates[$data["HEAD"]['page_type']])) {
                $template_name = $templates[$data["HEAD"]['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("/{{(area|text)\|(.+?)\|(.+?)}}/",
                    "{{field|$2}}", $tmp_page);
                if (empty($data['PAGE'])) {
                    $data['PAGE'] = preg_replace(
                        "/{{field\|(.+?)}}/", "", $tmp_page);
                } else {
                    set_error_handler(null);
                    $page_data = @unserialize(base64_decode(substr(
                        $data['PAGE'], strlen('<div>'),
                        -strlen('</div>'))));
                    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);
                }
            }
        } else if ($data["HEAD"]['page_type'] == 'page_and_feedback') {
            $just_thread = $data['DISCUSS_THREAD'];
            $thread_parent =
                $feed_model->getGroupItem($just_thread);
            $edit_or_source = ($data["CAN_EDIT"]) ? "edit" : "source";
            $search_array = [
                ["parent_id", "=", $just_thread, ""],
                ["pub_date", "", "", "DESC"]];
            $limit = (!empty($_REQUEST['limit'])) ?
                $parent->clean($_REQUEST['limit'], 'int') : 0;
            $results_per_page =  (!empty($_REQUEST['num'])) ?
                $parent->clean($_REQUEST['num'], 'int') :
                C\NUM_RESULTS_PER_PAGE;
            list($item_count, $pages) = $this->initializeFeedItems($data, [],
                $user_id, $search_array, -2, "krsort",
                $limit, $results_per_page);
            if ($limit + count($pages) == $item_count) {
                $begin_page = array_pop($pages);
                $data["WIKI_MEMBER_ACCESS"] = $begin_page["MEMBER_ACCESS"];
                $data['WIKI_PARENT_ID'] = $data['DISCUSS_THREAD'];
                $data['WIKI_GROUP_ID'] = $group_id;
            }
            $item_count--;
            $data['TOTAL_ROWS'] = $item_count;
            if ($data['TOTAL_ROWS'] == 0) {
                $data['NO_POSTS_YET'] = true;
            }
            $data['INCLUDE_SCRIPTS'][] =  "editor";
            $data['LIMIT'] = $limit;
            $data['RESULTS_PER_PAGE'] = $results_per_page;
            $data['PAGES'] = $pages;
            $data[C\p('CSRF_TOKEN')] = $parent->generateCSRFToken($user_id);
            $data['PAGING_QUERY'] = htmlentities(B\wikiUrl($data['PAGE_NAME'],
                true, $data['CONTROLLER'], $group_id)) .
                C\p('CSRF_TOKEN') . '='. $data[C\p('CSRF_TOKEN')] .
                "&amp;page_type=page_and_feedback";
            $data['WIKI_FEED_BASE'] = C\baseUrl() . "?c=". $data['CONTROLLER'] .
                "&amp;a=groupFeeds&amp;just_thread=".$data['DISCUSS_THREAD'] .
                "&amp;". C\p('CSRF_TOKEN') . '='. $data[C\p('CSRF_TOKEN')] .
                "&amp;page_type=page_and_feedback&amp;page_name=" .
                $data['PAGE_NAME'];
            if ($data['VIEW'] != 'api') {
                $data['SCRIPT'] .= " let nextPage = initNextResultsPage(" .
                    "$limit, {$data['TOTAL_ROWS']}, $results_per_page, ".
                    "'{$data['PAGING_QUERY']}', '', " .
                    "'results-container', 'result-batch');\n";
            }
        } else if ($data["HEAD"]['page_type'] == 'media_list') {
            if ($this->serveStaticFolderFile($data, $group_id,
                $data['PAGE_ID'], $sub_path, $user_id)) {
                return;
            }
            $data['INCLUDE_SCRIPTS'][] =  "editor";
            $data['RESOURCES_INFO'] =
                $wiki_model->getGroupPageResourceUrls($group_id,
                    $data['PAGE_ID'], $sub_path,
                    needs_descriptions_format:
                    $data["HEAD"]['update_description'] ?? "");
            $thumb_folder = $data['RESOURCES_INFO']['thumb_folder'] ?? "";
            if (!empty($thumb_folder) && $fp = fopen(self::RECOMMENDATION_FILE,
                "a")) {
                fwrite($fp, $group_id . "###" . $data['PAGE_ID'] . "###" .
                    $thumb_folder . "\n");
                fclose($fp);
            }
            $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'] . "');";
            }
            $this->addPodcastSourceStatus($data, $group_id,
                $data['PAGE_ID'], $sub_path);
        } else if ($data["HEAD"]['page_type'] == 'presentation' &&
            $data['CONTROLLER'] == 'group') {
            $data['page_type'] = 'presentation';
            $data['INCLUDE_SCRIPTS'][] =  "frise";
            $data['INCLUDE_STYLES'][] =  "frise";
        }
    }

    /**
     * anonymousReportPasses runs the checks an issue report from someone with
     * no account has to pass before it is taken. In order: an address already
     * in a growing timeout for failing these checks is turned away without
     * further work; a decoy form field, hidden from people and from screen
     * readers, must be empty; the form must have been on screen long enough for
     * a person to have read it; the browser must have done the arithmetic it
     * was set when the form was drawn; and the address must be under its
     * allowance of reports for the day. A failure of any of the middle checks
     * feeds the same growing timeout and sends the reporter back to the same
     * place, so a script cannot learn from the answer which check stopped it.
     * @param array &$data used by the view to draw any dynamic content; a
     *     report that is not taken puts the reason shown to the reporter here,
     *     the same wording whichever check stopped it
     * @param int $group_id id of the group the repository page belongs to
     * @param string $page_name name of the git repository wiki page
     * @param string $locale_tag language the pages are written for
     * @return bool whether the report should be taken
     */
    protected function anonymousReportPasses(&$data, $group_id, $page_name,
        $locale_tag)
    {
        $parent = $this->parent;
        $visitor_model = $parent->model("visitor");
        $address = L\remoteAddress();
        /* Somebody an editor has shut out of this repository is turned
           away before anything else is looked at, and told the same thing
           as anyone else whose report is not taken. */
        if ($parent->model("wiki")->isGitIssueBanned($group_id, $page_name,
            (substr(L\crawlHash(L\remoteAddress() . C\p('AUTH_KEY')), 0,
                C\GIT_ISSUE_REPORTER_TOKEN_LEN)), $locale_tag)) {
            $data['DISPLAY_MESSAGE'] = tl('social_component_issue_barred');
            return false;
        }
        /* Being over the day's allowance is a plain fact about how much
           has been sent and is worth saying, so a reporter knows to come
           back rather than guess at what went wrong. Failing the checks
           against form-filling machines is not, and keeps the same wording
           whichever check stopped it. */
        $waiting = $visitor_model->getVisitor($address,
            "git_issue_time_out");
        if (isset($waiting['END_TIME']) && $waiting['END_TIME'] > time()) {
            $data['DISPLAY_MESSAGE'] =
                tl('social_component_issue_not_taken');
            return false;
        }

        $tripped_bot_check = !empty($_POST[C\WIKI_FORM_HONEYPOT_FIELD]);
        $form_drawn_at = $_REQUEST['time'] ?? "";
        if (!$tripped_bot_check && ctype_digit((string)$form_drawn_at) &&
            time() - (int)$form_drawn_at < C\MIN_WIKI_FORM_DELAY) {
            $tripped_bot_check = true;
        }
        if (!$tripped_bot_check && !$parent->validatePostedHashCode()) {
            $tripped_bot_check = true;
        }
        if ($tripped_bot_check) {
            $visitor_model->updateVisitor($address,
                "git_issue_time_out");
            $data['DISPLAY_MESSAGE'] =
                tl('social_component_issue_not_taken');
            return false;
        }
        /* Counted last, so a report that fails a check does not spend
           any of the day's allowance. */
        if (!$visitor_model->withinDailyAllowance($address,
            "git_issue_day_count", C\MAX_ANON_GIT_ISSUES_ONE_DAY)) {
            $data['DISPLAY_MESSAGE'] =
                tl('social_component_issue_day_full');
            return false;
        }
        return true;
    }

    /**
     * addPodcastSourceStatus detects whether any feed or scrape podcast search
     * source downloads to the resource folder of the media-list wiki page being
     * viewed, and if so records in $data whether a download is in progress for
     * that folder right now. The Media List view uses this to show a live
     * downloading indicator or, when idle, an update button. Sources are
     * matched by resolving each podcast source's destination wiki page to the
     * same group, page, and sub-path that identify the current folder.
     * @param array &$data view data array; on return may carry PODCAST_FOLDER
     *     (the folder key), PODCAST_SOURCE_COUNT, and PODCAST_DOWNLOADING
     *     (bool) when the page is a destination
     * @param int $group_id group of the page being viewed
     * @param int $page_id wiki page being viewed
     * @param string $sub_path resource sub-folder being viewed, empty for the
     *     page's top resource folder
     */
    protected function addPodcastSourceStatus(&$data, $group_id,
        $page_id, $sub_path)
    {
        $parent = $this->parent;
        $source_model = $parent->model("source");
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $locale_tag = L\getLocaleTag();
        $folder_key =
            LMJ\PodcastDownloadJob::podcastFolderKey(
            $group_id, $page_id, $sub_path);
        $match_count = 0;
        foreach (['feed_podcast', 'scrape_podcast'] as $type) {
            $sources = $source_model->getMediaSources($type);
            foreach ($sources as $source) {
                $aux_parts = explode("###", html_entity_decode(
                    $source['AUX_INFO'], ENT_QUOTES));
                if (count($aux_parts) < 6) {
                    continue;
                }
                $wiki_page = $aux_parts[5];
                list($source_group_id, $source_page_id,
                    $source_sub_path, ) =
                    $wiki_model->getGroupIdPageIdSubPathFromName(
                    $wiki_page, $locale_tag);
                $source_key = LMJ\PodcastDownloadJob::podcastFolderKey(
                    $source_group_id, $source_page_id,
                    $source_sub_path);
                if ($source_key === $folder_key) {
                    $match_count++;
                }
            }
        }
        if ($match_count == 0) {
            return;
        }
        $data['PODCAST_FOLDER'] = $folder_key;
        $data['PODCAST_SOURCE_COUNT'] = $match_count;
        $data['PODCAST_STATE'] = $this->podcastFolderState($folder_key);
    }

    /**
     * podcastFolderState resolves the current state of one podcast destination
     * folder for the Media List status indicator. The states are "downloading"
     * while items are being fetched, "queued" once an immediate update has been
     * requested but the updater has not yet started it, "not_running" when an
     * update is requested but the media updater daemon is not active to carry
     * it out, and "idle" otherwise.
     * @param string $folder_key key from podcastFolderKey
     * @return string one of downloading, queued, not_running, idle
     */
    protected function podcastFolderState($folder_key)
    {
        if (LMJ\PodcastDownloadJob::isPodcastDownloading(
            $folder_key)) {
            return "downloading";
        }
        $marker = LMJ\PodcastDownloadJob::podcastRequestMarkerFile(
            $folder_key);
        clearstatcache(true, $marker);
        if (file_exists($marker)) {
            $daemons = L\CrawlDaemon::statuses();
            if (empty($daemons['MediaUpdater'])) {
                return "not_running";
            }
            return "queued";
        }
        return "idle";
    }

    /**
     * checkAuthRequirement checks whether the current user satisfies the page's
     * sign-in and access requirements. Scans the wiki contents for a
     * `[{require-signin|...|message}]` directive and enforces  access control
     * based on the current user's identity: - **Unauthenticated user**
     * (PUBLIC_USER_ID): If the directive is present, replaces the   page
     * content with the directive's final pipe-delimited segment as a warning
     * heading and sets `$data['AUTHORIZED']` to false. - **Authenticated user,
     * restricted to named users**: If more than one pipe-delimited value is
     * present, the final segment is treated as the warning message and the
     * preceding segments are treated as an allowlist of usernames. If the
     * current session username is not in the allowlist, the page is replaced
     * with the warning heading and `$data['AUTHORIZED']` is set to false. -
     * **Authorized user**: The `[{require-signin}]` directive is replaced with
     * a hidden input field (`CSVFORM[require_signin]=true`) so downstream form
     * handling is aware that sign-in was required. If no `require-signin`
     * directive is found, the method returns without modifying `$data`.
     * @param array &$data view data array, modified in place. Relevant keys: -
     *     'PAGE' (string) Raw wiki page content; may be rewritten. -
     *     'AUTHORIZED' (bool) Set to true initially; set to false if access is
     *     denied.
     * @param int $user_id ID of the currently authenticated user.
     *     C\PUBLIC_USER_ID indicates an unauthenticated (guest) session.
     * @return void nothing is handed back
     */
    protected function checkAuthRequirement(&$data, $user_id)
    {
        $data['AUTHORIZED'] = true;
        $auth_requirement = preg_match(
            "/\[{require-signin((?:\|[^}|\n]+)+)}\]/", $data['PAGE'] ?? "",
            $matches);
        $match_parts = array_filter(explode("|", ($matches[1] ?? "")));
        if ($user_id == C\PUBLIC_USER_ID && $auth_requirement) {
            $last_match = end($match_parts);
            $data['PAGE'] =
                "<h1 class='center-page warning'>{$last_match}</h1>";
            $data['AUTHORIZED'] = false;
            return;
        } else if ($auth_requirement) {
            if (count($match_parts) > 1) {
                $last_match = array_pop($match_parts);
                if (!in_array($_SESSION['USER_NAME'], $match_parts)) {
                    $data['PAGE'] =
                        "<h1 class='center-page warning'>{$last_match}</h1>";
                    $data['AUTHORIZED'] = false;
                }
            }
            $data['PAGE'] = preg_replace(
                "/\[{require-signin((?:\|[^}|\n]+)+)}\]/",
                "<input type='hidden' name='CSVFORM[require_signin]' ".
                "value='true'/>",
                $data['PAGE']);
        }
    }
    /**
     * initializeBallot manages the full life of a secret ballot for a wiki
     * group page. Determines the current ballot state based on the presence of
     * a secrets file  and a CSV results file, then transitions between the
     * following modes: - **ballot-init**: No secrets file exists yet. Renders
     * the witness credential form. On valid witness submission, creates the
     * ballot secrets file and redirects with a success message. - **count-
     * ballots**: Secrets file exists and the "count-ballots" mode is requested.
     * Renders the witness authorization form. On valid witness submission,
     * decrypts and tallies all cast votes into a CSV results file and redirects
     * with a success or error message. - **ballot-concluded**: A CSV results
     * file already exists, meaning voting is closed. Loads the results and
     * initializes the client-side Spreadsheet JS to render histograms and raw
     * vote data. - **active ballot**: Secrets file exists but counting has not
     * been requested.  Replaces the `[{secret-ballot}]` page placeholder with a
     * "Count Ballots" link for editor users, or removes it for voters who are
     * not editors otherwise. Authorization is enforced at two levels: -
     * `$data['CAN_UPDATE_POLL']` is set to false if the current user is neither
     * the group owner nor has editor status. - witness logins are validated
     * using `checkValidSignin()` before any ballot operation is performed. IF
     * LDAP is enable might involved LDAP
     * @param array&$data view data array, modified in place. Relevant keys read
     *     and written: - 'PAGE_ID' (int)    ID of the current wiki page. -
     *     'GROUP' (array)  Group metadata including OWNER_ID and STATUS. -
     *     'PAGE' (string) Raw wiki page content; may be rewritten. -
     *     'FORM_HASH' (string) Hash identifying this ballot form. - 'MODE'
     *     (string) Set to 'ballot-init', 'count-ballots', or 'ballot-concluded'
     *     as appropriate. - 'CAN_UPDATE_POLL'  (bool)   Whether the user may
     *     administer the poll. - 'WITNESSES' (array)  Passed to the view when a
     *     witness form is needed. - 'VOTE_INFO' (array)  Parsed CSV results,
     *     set in concluded mode. - 'INCLUDE_SCRIPTS' (array)  Client-side
     *     scripts to enqueue. - 'SCRIPT' (string) Inline JS appended for the
     *     spreadsheet widget. - 'SPREADSHEET' (bool)   Flag indicating
     *     spreadsheet data is present. - C\p('CSRF_TOKEN')  (string) CSRF token
     *     passed through to the view.
     * @param int    $user_id    ID of the currently authenticated user.
     * @param int    $group_id   ID of the group that owns the wiki page.
     * @param string $sub_path   Sub-path of the wiki page resources (not used)
     * @param array  $witnesses  Ordered list of witness identifier strings for
     *     key pair derivation.
     * @return void|mixed        Returns void in most cases. Returns a redirect
     *     response on successful or failed witness submission, or when a key
     *     mismatch is detected.
     */
    public function initializeBallot(&$data, $user_id, $group_id, $sub_path,
        $witnesses)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $signin_model = $parent->model("signin");
        $default_folders = $wiki_model->getGroupPageResourcesFolders(
            $group_id, $data['PAGE_ID']);
        $csv_filepath = $default_folders[0] . '/' . C\WIKI_FORM_CSV_FILE;
        $csv_exists = file_exists($csv_filepath);
        $secrets_filepath = $default_folders[0] . '/' .
            C\WIKI_FORM_SECRETS_FILE;
        /* The round a ballot gathers its witnesses through waits in a
           file of its own, since the secrets file existing is what says a
           ballot has begun, and a round happens before one begins. */
        $round_filepath = $default_folders[0] . '/' .
            C\WIKI_FORM_ROUND_FILE;
        $data['CAN_UPDATE_POLL'] = true;
        $group = $data["GROUP"] ?? [];
        /* Which of the ballot's screens the person is on rides in the
           mode, so a redirect that drops it lands them back on the vote
           page rather than where they were working. */
        $preserve_fields =
            ['arg', 'page_name', 'group_name', 'settings',
            'caret', 'scroll_top', 'sf', 'ballot_message', 'mode'];
        if (empty($group) || (
            $group["OWNER_ID"] != $user_id &&
            $group["STATUS"] != C\EDITOR_STATUS &&
            ($group["MEMBER_ACCESS"] != C\GROUP_READ_WIKI ||
            $group["STATUS"] != C\ACTIVE_STATUS
            ))) {
            $data['CAN_UPDATE_POLL'] = false;
        }
        /* A witness may be asked to take their part by mail rather than
           at this form. Sending the invitations ends any sent before, so
           an older mail no longer opens the ballot. This is the stage
           that starts a ballot, so it stands aside once one has been
           made: a witness asked while a poll is being closed is written
           to further down, with a link for that stage rather than this
           one. */
        if (!$csv_exists && !file_exists($secrets_filepath) &&
            !empty($_REQUEST['WITNESS_EMAIL'])) {
            $asked = $parent->clean($_REQUEST['WITNESS_EMAIL'], "string");
            $sent = $this->emailBallotWitnesses($parent, $signin_model,
                $round_filepath, $data, $witnesses, $asked, "start");
            return $parent->redirectWithMessage(
                $this->ballotInviteSaying($sent, $asked),
                $preserve_fields);
        }
        if (!$csv_exists && !empty($_REQUEST['WITNESS_SUBMIT'])) {
            $num_witnesses = count(($_REQUEST['WITNESS'] ?? []) );
            $passwords = $_REQUEST['PASSWORD'] ?? [];
            $auth_passed = ($num_witnesses > 0);
            for ($i = 0; $i < $num_witnesses; $i++) {
                $witnesses[$i] = $parent->clean(
                    $_REQUEST['WITNESS'][$i], "string");
                /* A witness who has already taken their part, by
                   following a link sent to them, is not asked for a
                   password and does not have a box on the form. Reading
                   one for them turned every submission into a failed
                   sign-in that said nothing. */
                if (($passwords[$i] ?? "") === "") {
                    continue;
                }
                if (!$signin_model->checkValidSignin(
                    $witnesses[$i], $passwords[$i])) {
                    $auth_passed = false;
                    break;
                }
            }
        }
        if ($csv_exists) {
            $data['MODE'] = "ballot-concluded";
            $data['VOTE_INFO'] = L\parseCsv(file_get_contents($csv_filepath));
            $data['INCLUDE_SCRIPTS'][] = 'spreadsheet';
            $data['SCRIPT'] .= 'spreadsheet = new Spreadsheet(' .
                '"histograms",' . json_encode($data['VOTE_INFO']) . ");".
                'spreadsheet.drawHistograms({has_column_headers:true,' .
                'ignore_values:[""], ignore_columns:' .
                '["VOTE_BALLOT_HASH","RECEIPT"],' .
                'stop_condition:["FORM_HASH", ""]});';
            $data['SCRIPT'] .= 'spreadsheet.setContainer("spreadsheet");'.
                "spreadsheet.draw();";
            $data['SPREADSHEET'] = true;
        } else if (file_exists($secrets_filepath)) {
            if (!empty($_REQUEST['mode']) &&
                $_REQUEST['mode'] == 'count-ballots') {
                if (!empty($_REQUEST['WITNESS_SUBMIT'])) {
                    if (!$auth_passed) {
                        unset($_REQUEST['route']);
                        return $parent->redirectWithMessage(
                            $this->ballotTrouble(
                            tl('social_component_witness_auth_fail')),
                            $preserve_fields);
                    }
                    /* A password typed here keeps that witness's part
                       the way following a mailed link does, so the
                       witnesses may close the poll a few at a time and
                       the counting below waits until they have all
                       acted. At this stage the value each witness drew
                       when the ballot began is used again, since the
                       key is rebuilt from those. */
                    $kept = $this->keepTypedWitnessParts($signin_model,
                        $round_filepath, $witnesses, $passwords, $data,
                        $secrets_filepath);
                    if ($kept === 0) {
                        unset($_REQUEST['route']);
                        return $parent->redirectWithMessage(
                            $this->ballotTrouble(
                            tl('social_component_ballot_no_password')),
                            $preserve_fields);
                    }
                }
                if (!empty($_REQUEST['WITNESS_EMAIL'])) {
                    $asked = $parent->clean($_REQUEST['WITNESS_EMAIL'],
                        "string");
                    $sent = $this->emailBallotWitnesses($parent,
                        $signin_model, $round_filepath, $data,
                        $witnesses, $asked, "count");
                    return $parent->redirectWithMessage(
                        $this->ballotInviteSaying($sent, $asked),
                        $preserve_fields);
                }
                $data['MODE'] = "count-ballots";
                $data['WITNESSES'] = $witnesses;
                $this->addWitnessProgress($data, $signin_model,
                    $round_filepath, $witnesses, $group, $user_id);
                /* Counting needs the value every witness's password
                   makes, and a witness who answered by mail left theirs
                   sealed. Once they are all in the ballot is counted. */
                if (count($data['WITNESS_DONE']) >= count($witnesses) &&
                    count($witnesses) > 0) {
                    $parts = $signin_model->openWitnessShares(
                        $round_filepath, $data['FORM_HASH']);
                    $hashes = [];
                    foreach ($witnesses as $at => $witness) {
                        if (empty($parts[$witness])) {
                            $hashes = [];
                            break;
                        }
                        $hashes[$at] = $parts[$witness]["hash"];
                    }
                    if (!empty($hashes)) {
                        $message = $signin_model->countBallotFile(
                            $secrets_filepath, $csv_filepath,
                            $data['FORM_HASH'], $witnesses, [], $hashes);
                        $signin_model->closeWitnessRound($round_filepath,
                            $data['FORM_HASH']);
                        unset($_REQUEST['route']);
                        if ($message == SigninModel::KEY_MISMATCH) {
                            return $parent->redirectWithMessage(
                                $this->ballotTrouble(
                                tl('social_component_key_error')),
                                $preserve_fields);
                        }
                        $_REQUEST['ballot_message'] = $message;
                        return $parent->redirectWithMessage(
                            tl('social_component_ballots_counted'),
                            $preserve_fields);
                    }
                }
                return;
            } else {
                $end_poll = "";
                if ($data['CAN_UPDATE_POLL']) {
                    $ballot_request = array_merge($_REQUEST,
                        ["mode" => "count-ballots"]);
                    unset($ballot_request['route'],
                        $ballot_request['noredirect']);
                    $end_poll = "<div class='csv-form-field'
                        ><a href='.?" . http_build_query($ballot_request) .
                        "'>" .tl('social_component_count_ballots').
                        "</a></div>";
                }
                $data['PAGE'] = preg_replace(
                    '/\[\{secret-ballot((?:\|[^{|\n]+)+)\}\]/si',
                    $end_poll, $data['PAGE']);
            }
        } else {
            $data['MODE'] = "ballot-init";
            if (!empty($_REQUEST['WITNESS_SUBMIT'])) {
                if (!$auth_passed) {
                    return $parent->redirectWithMessage(
                        $this->ballotTrouble(
                        tl('social_component_witness_auth_fail')),
                        $preserve_fields);
                }
                /* A password typed here keeps that witness's part in
                   the round the same way following a mailed link does,
                   so the two ways may be mixed in one ballot and the
                   ballot is made below once every witness has acted. */
                $kept = $this->keepTypedWitnessParts($signin_model,
                    $round_filepath, $witnesses, $passwords, $data);
                if ($kept === 0) {
                    return $parent->redirectWithMessage(
                        $this->ballotTrouble(
                        tl('social_component_ballot_no_password')),
                        $preserve_fields);
                }
            }
            $data['WITNESSES'] = $witnesses;
            $this->addWitnessProgress($data, $signin_model,
                $round_filepath, $witnesses, $group, $user_id);
            /* Once every witness has contributed, whichever way each of
               them did it, the ballot can be made. */
            if (count($data['WITNESS_DONE']) >= count($witnesses) &&
                count($witnesses) > 0) {
                $made = $signin_model->finishWitnessRound($round_filepath,
                    $data['FORM_HASH'], $witnesses);
                if ($made === false) {
                    $signin_model->closeWitnessRound($round_filepath,
                        $data['FORM_HASH']);
                    return $parent->redirectWithMessage(
                        tl('social_component_ballot_compromised'),
                        $preserve_fields);
                }
                /* The ballot's key is built from the value every
                   witness's password made, which the round hands back
                   alongside the values they drew. Leaving those out
                   built the key from empty passwords, and counting the
                   ballot with the real ones then could not rebuild
                   it. */
                $signin_model->createBallotFile($secrets_filepath,
                    $data['FORM_HASH'], $witnesses, [], $made[0],
                    $made[4]);
                $signin_model->closeWitnessRound($round_filepath,
                    $data['FORM_HASH']);
                return $parent->redirectWithMessage(
                    tl('social_component_ballot_initialized'),
                    $preserve_fields);
            }
            return;
        }
    }
    /**
     * takeWitnessPart takes one witness's part in a ballot, for a witness who
     * has followed the link sent to them rather than come to the form. The link
     * is refused where it has expired, where it is not one this site sent,
     * where the set of invitations it belongs to has been replaced, or where
     * that witness has already acted. Otherwise the witness gives their ballot
     * password, their share is worked out as it is at the form, and it is kept
     * sealed until every witness has acted.
     * @param array $data fields for the view
     * @param int $user_id who is following the link
     * @param int $group_id which group the page belongs to
     */
    public function takeWitnessPart(&$data, $user_id, $group_id)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $signin_model = $parent->model("signin");
        $data["MODE"] = "ballot-part";
        $page_id = $parent->clean($_REQUEST['page_id'] ?? 0, "int");
        $witness = $parent->clean($_REQUEST['ballot_witness'] ?? "",
            "string");
        $stage = $parent->clean($_REQUEST['ballot_stage'] ?? "", "string");
        $expires = $parent->clean($_REQUEST['ballot_expires'] ?? 0, "int");
        $token = $parent->clean($_REQUEST['ballot_token'] ?? "", "string");
        $folders = $wiki_model->getGroupPageResourcesFolders($group_id,
            $page_id);
        $secrets_filepath = $folders[0] . '/' . C\WIKI_FORM_SECRETS_FILE;
        /* The round a ballot gathers its witnesses through waits in a file of
           its own, since the secrets file existing is what says a ballot has
           begun. */
        $round_filepath = dirname($secrets_filepath) . '/' .
            C\WIKI_FORM_ROUND_FILE;
        $page_name = $this->ballotIdForPage($group_model, $page_id);
        /* The page bar and the wiki screen around this one are drawn
           whatever comes of the link, and both ask which page they are
           on, so the name is set before anything can turn the witness
           away. */
        $data['PAGE_NAME'] = $page_name;
        $data['BALLOT_WITNESS'] = $witness;
        $data['BALLOT_STAGE'] = $stage;
        $refusal = $signin_model->witnessLinkRefusal($round_filepath,
            $token, $page_id, $page_name, $witness, $stage, $expires);
        $data['BALLOT_GOOD'] = ($refusal == "");
        if ($refusal == SigninModel::LINK_TAKEN) {
            $data['BALLOT_SAYS'] =
                tl('social_component_ballot_already_taken');
            return;
        }
        if ($refusal == SigninModel::LINK_EXPIRED) {
            $data['BALLOT_SAYS'] =
                tl('social_component_ballot_link_expired');
            return;
        }
        if ($refusal != "") {
            $data['BALLOT_SAYS'] = tl('social_component_ballot_link_bad');
            return;
        }
        if (empty($_REQUEST['BALLOT_PASSWORD'])) {
            return;
        }
        $password = $_REQUEST['BALLOT_PASSWORD'];
        if (!$signin_model->checkValidSignin($witness, $password)) {
            $data['BALLOT_SAYS'] =
                tl('social_component_witness_auth_fail');
            return;
        }
        /* The two values a witness contributes are drawn here, the same
           two the form draws when every witness is present, and sealed
           with the round's public key so nothing readable waits in the
           file while the other witnesses take their turns. */
        $round = $signin_model->witnessRoundKept($round_filepath);
        if (empty($round['public'])) {
            $data['BALLOT_SAYS'] = tl('social_component_ballot_no_round');
            return;
        }
        /* At the start a witness draws a fresh random value. At the close
           the ballot already holds the one they drew then, and the value
           their password makes has to be worked out from that same one or
           it will not rebuild the key. */
        $random = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
        if ($stage == "count") {
            /* The witnesses are named in the page's own text, and the
               record of a page holds what it is called rather than what
               it says, so the text is asked for. Read from the record,
               the list came back empty and every witness closing a poll
               by mail was worked out from the wrong value, which left
               the ballot's key unable to be rebuilt. */
            $named = $this->witnessesNamedOnPage(
                $wiki_model->getPageBodyByPageId($page_id));
            $at = array_search($witness, $named);
            if ($at === false) {
                $data['BALLOT_SAYS'] =
                    tl('social_component_ballot_no_round');
                return;
            }
            $random = $signin_model->witnessSeedKept($secrets_filepath,
                $at);
        }
        if ($random === false) {
            $data['BALLOT_SAYS'] = tl('social_component_ballot_no_round');
            return;
        }
        $signin_model->keepWitnessShare($round_filepath, $witness,
            $round['public'], $random,
            hash('sha256', $random . $witness . $password, true));
        $data['BALLOT_SAYS'] = tl('social_component_ballot_part_taken');
        /* There is nothing left for this witness to type, so the screen
           shows what happened and no longer asks for a password. */
        $data['BALLOT_DONE'] = true;
    }

    /**
     * formFieldIsEmpty whether a form field came back with nothing in it. A
     * list of choices nobody has touched carries the value standing for nothing
     * picked rather than an empty one, so a field asking to be filled in was
     * taken as answered by a list still showing its opening dashes, and an
     * empty form was accepted with a receipt.
     * @param string $value what the form sent back for the field
     * @return bool whether it amounts to nothing having been given
     */
    public function formFieldIsEmpty($value)
    {
        return $value === "" || $value === null ||
            $value === "empty" || $value === self::NOTHING_PICKED;
    }
    /**
     * keepTypedWitnessParts keeps the part of every witness who typed their
     * password on the start form, sealed in the round the same way a witness
     * who follows a mailed link has theirs kept. Doing it the one way lets the
     * two ways be mixed in one ballot: whoever is left can type their password
     * later or follow their own link, and the ballot is made once everybody has
     * acted.
     * @param object $signin_model model holding the round
     * @param string $round_filepath the file the round waits in
     * @param array $witnesses who the witnesses are, in the order the page
     *     names them
     * @param array $passwords what was typed, by the same order
     * @param array $data fields for the view, holding the page's hash
     * @param string $secrets_filepath the ballot's secrets file when a poll is
     *     being closed, so each witness's part is worked out from the value
     *     they drew when the ballot began; the empty string while a ballot is
     *     being started, where each witness draws a fresh one
     * @return int how many parts were kept
     */
    public function keepTypedWitnessParts($signin_model, $round_filepath,
        $witnesses, $passwords, $data, $secrets_filepath = "")
    {
        if (empty($signin_model->witnessRoundKept(
            $round_filepath)['public'])) {
            $signin_model->openWitnessRound($round_filepath,
                $data['FORM_HASH'] ?? "");
        }
        $round = $signin_model->witnessRoundKept($round_filepath);
        if (empty($round['public'])) {
            return 0;
        }
        $already = $signin_model->witnessSharesKept($round_filepath);
        $kept = 0;
        foreach ($witnesses as $at => $witness) {
            $password = $passwords[$at] ?? "";
            if ($password === "" || !empty($already[$witness])) {
                continue;
            }
            if ($secrets_filepath === "") {
                $random = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
            } else {
                /* Closing a poll rebuilds the key the ballot was made
                   with, so the value this witness drew when it began
                   has to be the one used again. */
                $random = $signin_model->witnessSeedKept($secrets_filepath,
                    $at);
                if ($random === false) {
                    continue;
                }
            }
            $signin_model->keepWitnessShare($round_filepath, $witness,
                $round['public'], $random,
                hash('sha256', $random . $witness . $password, true));
            $kept++;
        }
        return $kept;
    }

    /**
     * ballotTrouble marks something that went wrong so it is shown in red. The
     * line that tells somebody what happened looks the same whether their
     * password was taken or refused, and a refusal that reads like a receipt is
     * easy to walk past.
     * @param string $said what to tell the person
     * @return string the same, marked to be shown in red
     */
    public function ballotTrouble($said)
    {
        return "<span class='red'>" . $said . "</span>";
    }
    /**
     * ballotInviteSaying says what happened when a witness, or every witness,
     * was written to. Writing to one person is worth naming them, since the
     * screen offers a way of writing to each in turn and a count of one says
     * nothing about which.
     * @param int $sent how many witnesses were written to
     * @param string $asked which witness was asked for, or a star for every
     *     witness
     * @return string what to tell the person who pressed
     */
    public function ballotInviteSaying($sent, $asked)
    {
        if ($sent < 1) {
            return $this->ballotTrouble(
                tl('social_component_ballot_invite_none'));
        }
        if ($asked != "*") {
            return tl('social_component_ballot_invite_one_sent', $asked);
        }
        return tl('social_component_ballot_invites_sent', $sent);
    }
    /**
     * witnessesNamedOnPage gives the witnesses a page names for its secret
     * ballot, in the order the page names them and numbered from the first. A
     * witness's place in that list says which of the ballot's values is theirs,
     * so every part of the site that reads the list has to number it the same
     * way, and the page is read in the form it is stored in rather than the
     * form somebody typed.
     * @param string $body the page's source
     * @return array the witnesses named, or an empty list where the page names
     *     no ballot
     */
    public function witnessesNamedOnPage($body)
    {
        if (!preg_match('/\[\{secret-ballot((?:\|[^{|\n]+)+)\}\]/si',
            $body, $found)) {
            return [];
        }
        return array_values(array_map('trim',
            array_filter(explode("|", $found[1]), 'strlen')));
    }
    /**
     * ballotIdForPage gives the name a ballot's invitations are signed against.
     * Both the sending of an invitation and the following of one work this out
     * here, from the page's own record, so the two cannot fall out of step: a
     * page called one thing on the screen and another in the database would
     * otherwise sign one name and check the other, and every link would come
     * back as not one this site sent.
     * @param object $wiki_model the model that knows the page
     * @param int $page_id which page the ballot is on
     * @return string the name to sign against, or the empty string where there
     *     is no such page
     */
    public function ballotIdForPage($wiki_model, $page_id)
    {
        $page_info = $wiki_model->getPageInfoByPageId($page_id);
        return $page_info['PAGE_NAME'] ?? "";
    }
    /**
     * ballotInviteLink builds the web address a witness follows to take their
     * part in a ballot. It has to name the machine as well as the page, since
     * it is read in somebody's mail rather than on the site: a link beginning
     * at a slash means nothing there and takes the witness nowhere.
     * @param string $base_url the site's address up to where a query begins,
     *     naming the scheme, the host, and any port and path
     * @param int $group_id which group the ballot's page belongs to
     * @param int $page_id which page the ballot is on
     * @param string $witness whose link this is
     * @param string $stage which stage the link is for, start or count
     * @param int $expires when the link stops being good
     * @param string $token the token that stands for this invitation
     * @return string the whole address to put in the mail
     */
    public function ballotInviteLink($base_url, $group_id, $page_id,
        $witness, $stage, $expires, $token)
    {
        return $base_url . "?c=group&a=wiki&arg=ballot" .
            "&group_id=" . $group_id .
            "&page_id=" . $page_id .
            "&ballot_stage=" . $stage .
            "&ballot_witness=" . urlencode($witness) .
            "&ballot_expires=" . $expires .
            "&ballot_token=" . urlencode($token);
    }
    /**
     * addWitnessProgress says which witnesses have already taken their part in
     * a ballot, and whether the others may be asked by email. A witness who has
     * acted, at the form or by following a link sent to them, is shown as done
     * rather than asked for a password again. Asking by email is offered only
     * where this Yioop can send mail at all, and not where there is a single
     * witness who owns the page: one person acting alone can simply type their
     * password, and offering to mail themselves would be so much furniture.
     * @param array $data fields for the view
     * @param object $signin_model model holding the ballot's shares
     * @param string $round_filepath the file the round waits in
     * @param array $witnesses who the witnesses are
     * @param array $group the group the page belongs to
     * @param int $user_id who is looking at the form
     */
    public function addWitnessProgress(&$data, $signin_model,
        $round_filepath, $witnesses, $group, $user_id)
    {
        /* A round is opened the first time the screen is shown, since
           every part that arrives after that is sealed with its public
           key. */
        if (empty($signin_model->witnessRoundKept(
            $round_filepath)['public'])) {
            $signin_model->openWitnessRound($round_filepath,
                $data['FORM_HASH'] ?? "");
        }
        $done = [];
        foreach ($signin_model->witnessSharesKept($round_filepath)
            as $witness => $sealed) {
            $done[$witness] = true;
        }
        $data['WITNESS_DONE'] = $done;
        /* Where reading a file does not move its access time, the site
           cannot tell whether anything read the key that opens the parts
           while they waited, and the person starting the ballot should
           know that before they begin. */
        $data['KEY_FILE_WATCH'] = $signin_model->keyFileWatchfulness();
        $alone = (count($witnesses) == 1 &&
            ($group["OWNER_ID"] ?? 0) == $user_id);
        $data['CAN_EMAIL_WITNESSES'] = !$alone &&
            ML\MailSiteFactory::canSendMail();
    }
    /**
     * emailBallotWitnesses sends one or every witness a link inviting them to
     * take their part in a ballot without coming to the form. Sending a set of
     * invitations ends any sent before, so a witness holding an older mail
     * finds its link no longer works.
     * @param object $parent controller that called this
     * @param object $signin_model model holding the ballot's secrets
     * @param string $round_filepath the file the round waits in, which holds
     *     the nonce an invitation is signed against
     * @param array $data fields from the controller
     * @param array $witnesses who the witnesses are
     * @param string $asked which witness to write to, or a star for all
     * @param string $stage which stage the invitation is for
     * @return int how many were written to, counting only those the mail server
     *     took
     */
    public function emailBallotWitnesses($parent, $signin_model,
        $round_filepath, $data, $witnesses, $asked, $stage)
    {
        $user_model = $parent->model("user");
        /* Who is to be written to is settled before anything is sent,
           because minting the nonce ends every invitation sent before
           it. A witness written to moments ago is left alone: the
           button was pressed once, and a request that reaches the site
           twice would otherwise leave them holding a link the site no
           longer knows. */
        $writing_to = [];
        $just_done = 0;
        foreach ($witnesses as $witness) {
            if ($asked != "*" && $asked != $witness) {
                continue;
            }
            $user = $user_model->getUser($witness);
            if (empty($user['EMAIL'])) {
                continue;
            }
            if ($signin_model->witnessInvitedJustNow($round_filepath,
                $witness, C\p('BALLOT_INVITE_REPEAT_WAIT'))) {
                $just_done++;
                continue;
            }
            $writing_to[$witness] = $user['EMAIL'];
        }
        if (empty($writing_to)) {
            return $just_done;
        }
        $nonce = $signin_model->ballotInviteNonce($round_filepath, true);
        $expires = time() + C\p('BALLOT_INVITE_LIFETIME');
        $ballot_id = $this->ballotIdForPage($parent->model("group"),
            $data['PAGE_ID']);
        $sent = $just_done;
        foreach ($writing_to as $witness => $address) {
            $token = $signin_model->ballotInviteToken($data['PAGE_ID'],
                $ballot_id, $witness, $stage, $expires, $nonce);
            $where = $this->ballotInviteLink(C\baseUrl(),
                $data['GROUP']['GROUP_ID'] ?? 0, $data['PAGE_ID'],
                $witness, $stage, $expires, $token);
            $client = ML\MailSiteFactory::outboundSmtpClient();
            /* A witness is waiting on their invitation, so this goes as
               soon as the site is set up to let it: send() puts it in the
               folder the media updater drains only where Use a Queue is
               ticked, and otherwise hands it over during this request. */
            $went = $client->send(
                tl('social_component_ballot_invite_subject',
                $data['PAGE_NAME']), C\p('MAIL_SENDER'), $address,
                tl('social_component_ballot_invite_body',
                C\p('SITE_NAME'), $data['PAGE_NAME'], $where));
            if ($went) {
                $signin_model->noteWitnessInvited($round_filepath,
                    $witness, time());
                $sent++;
            }
        }
        return $sent;
    }
    /**
     * remakeResourceThumb makes the small picture of one resource again,
     * taking away the picture that is there first. A document saved
     * before Yioop could read its kind has no picture at all, and one
     * saved before the reader was improved has a poorer picture than it
     * would be given today, so somebody who may edit the page can ask
     * for the work to be done again.
     *
     * @param array &$data the values the view was going to be given,
     *     which say which page and which folder are meant
     * @param string $name the name of the resource
     * @return bool whether a picture is there afterwards
     */
    public function remakeResourceThumb(&$data, $name)
    {
        $parent = $this->parent;
        if (empty($data['CAN_EDIT']) || empty($data['PAGE_ID'])) {
            return false;
        }
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $folders = $wiki_model->getGroupPageResourcesFolders(
            $data['GROUP']['GROUP_ID'] ?? 0, $data['PAGE_ID'],
            $data['SUB_PATH'] ?? "");
        if ($folders === false) {
            return false;
        }
        list($folder, $thumb_folder) = $folders;
        if (!file_exists("$folder/$name")) {
            return false;
        }
        /* The picture that is there is taken away first, since the
           maker leaves one that is already there alone. */
        foreach ([".webp", C\MOVING_THUMB_ENDING, ".jpg"] as $ending) {
            $held = "$thumb_folder/$name" . $ending;
            if (file_exists($held)) {
                unlink($held);
            }
        }
        clearstatcache();
        return $group_model->makeThumbStripExif($name, $folder,
            $thumb_folder);
    }
    /**
     * initUserResourcePreferences reads what somebody asked of the
     * resources on a page before the page is drawn: which of them to
     * stop marking as seen, how they should be laid out, and whether a
     * small picture should be made again.
     *
     * @param array &$data the values the view will be given, which this
     *     adds the reader's own settings to
     */
    public function initUserResourcePreferences(&$data)
    {
        $parent = $this->parent;
        /* Somebody editing a page may ask for a resource's small
           picture to be made again. A document saved before Yioop could
           read its kind has no picture, and one saved before the reader
           was improved has a poorer picture than it would get today. */
        if (!empty($_REQUEST['remake_thumb'])) {
            $made = $this->remakeResourceThumb($data,
                $parent->clean($_REQUEST['remake_thumb'], 'file_name'));
            /* The asking is answered on its own, without the page being
               drawn again, since the browser only wants to know the
               work is done before it fetches the new picture. */
            if (($_REQUEST['arg'] ?? "") == "remake_thumb") {
                echo json_encode(["made" => $made]);
                \seekquarry\atto\webExit();
            }
        }
        // Delete a marked diamond icon from video list
        $changed = false;
        if (!empty($_REQUEST['clear']) && !empty($_SESSION['seen_media'])
            && is_array($_SESSION['seen_media']) && !empty($data['PAGE_ID'])) {
            $media_name = $parent->clean($_REQUEST['clear'], 'file_name');
            $type = UrlParser::getDocumentType($media_name);
            if ($type != "") {
                $media_name = UrlParser::getDocumentFilename($media_name);
                $media_name = urlencode($media_name);
                $media_name = "$media_name.$type";
            }
            $sub_path = $data['SUB_PATH'] ?? "";
            $hash_id = L\crawlHash($data['PAGE_ID']. $media_name . $sub_path);
            if (in_array($hash_id, $_SESSION['seen_media'])) {
                $_SESSION['seen_media'] = array_diff($_SESSION['seen_media'],
                    [$hash_id]);
                $changed = true;
            }
        }
        $sub_path = $data['SUB_PATH'] ?? "";
        $folder_hash_id = L\crawlHash(($data['PAGE_ID'] ?? -1) . $sub_path);
        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'][$folder_hash_id] = $_REQUEST['sort'];
            $changed = true;
        }
        if (!empty($_REQUEST['layout']) && in_array($_REQUEST['layout'],
            ['list', 'grid', 'detail'])) {
            unset($_SESSION['layouts'][$folder_hash_id]);
            $_SESSION['layouts'][$folder_hash_id] = $_REQUEST['layout'];
            if (count($_SESSION['layouts']) > 10) {
                $first_key = array_key_first($_SESSION['layouts']);
                unset($_SESSION['layouts'][$first_key]);
            }
            $changed = true;
        }
        $data['CURRENT_LAYOUT'] = $_SESSION['layouts'][$folder_hash_id] ??
            "list";
        $data['CURRENT_SORT'] = $_SESSION['media_sorts'][$folder_hash_id] ?? "";
        if ($changed) {
            // only saves session in not PUBLIC_USER_ID
            $parent->model("user")->setUserSession($_SESSION['USER_ID'] ??
                C\PUBLIC_USER_ID, $_SESSION);
        }
        $this->sortWikiResources($data);
    }

    /**
     * sortWikiResources used to sort the resources on a wiki pages either for
     * display in case of reading a media list or to help find resources in the
     * case of a user using edit mode
     * @param array &$data data to be sent to the view. The
     *     $data["RESOURCES_INFO"]['resources'] array of resources will be
     *     sorted according to the wiki page's settings as given in
     *     $data["HEAD"]['sort']
     */
        public function sortWikiResources(&$data)
    {
        if (empty($data["RESOURCES_INFO"]['resources'])) {
            return;
        }
        $folder_hash_id = L\crawlHash(($data['PAGE_ID'] ?? "").
            ($data['SUB_PATH'] ?? ""));
        if (!empty($_SESSION['media_sorts'][$folder_hash_id])) {
            list($sort_field, $direction) = explode("_",
                $_SESSION['media_sorts'][$folder_hash_id]);
            $callback = ($direction == 'asc') ?
                "orderCallback" : "rorderCallback";
            if ($sort_field == 'name') {
                $callback = ($direction == 'asc') ?
                    "stringROrderCallback" : "stringOrderCallback";
            }
        } else {
            if (empty($data["HEAD"]['default_sort'])) {
                return;
            }
            set_error_handler(null);
            $sort_map = @unserialize(L\webdecode(
                $data["HEAD"]['default_sort']));
            restore_error_handler();
            $sort_key = (empty($data['SUB_PATH'])) ? "." : $data['SUB_PATH'];
            $sort_key = rtrim($sort_key, '/');
            if (empty($sort_map[$sort_key])) {
                return;
            }
            $sort_field = substr($sort_map[$sort_key], 1);
            $callback = ($sort_map[$sort_key][0] == 'r') ?
                "rorderCallback" : "orderCallback";
            if ($sort_field == 'name') {
                $callback = ($sort_map[$sort_key][0] == 'r') ?
                    "stringROrderCallback" : "stringOrderCallback";
            }
        }
        $callback_name = C\NS_LIB . $callback;
        $callback_name(null, null, $sort_field);
        usort($data["RESOURCES_INFO"]["resources"], C\NS_LIB . $callback);
    }

    /**
     * serveStaticFolderFile serves one file out of a page's resources when the
     * page is set to be a static HTML folder, and says whether it did. A folder
     * of generated documentation, or any other small site, refers to its own
     * stylesheets, scripts and pages by paths relative to itself, so it only
     * works if those paths fetch the files they name. A folder is answered with
     * the first of the page's index files that is in it, the way a web server
     * answers one; if none is, the page's ordinary resource listing is shown
     * when directory indexes are on, and nothing is found when they are off.
     * What is served comes only from the page's own resource folder, so no path
     * can reach anything else the site holds.
     * @param array &$data fields for the view, whose HEAD holds the page's
     *     settings and whose PAGE_NAME names it
     * @param int $group_id which group the page belongs to
     * @param int $page_id which page within that group
     * @param string $sub_path what was asked for beneath the page, empty for
     *     the folder itself
     * @param int $user_id who is asking, used to offer an edit button to
     *     someone allowed to edit the page
     * @return bool whether the request was answered here, in which case the
     *     caller has nothing left to do
     */
    public function serveStaticFolderFile(&$data, $group_id, $page_id,
        $sub_path, $user_id)
    {
        $head = $data["HEAD"] ?? [];
        $settings = $this->staticFolderSettings($head);
        if (empty($settings['static_html_folder'])) {
            return false;
        }
        $folders = $this->parent->model("group")
            ->getGroupPageResourcesFolders($group_id, $page_id);
        if (!is_array($folders) || empty($folders[0])) {
            return false;
        }
        $path = $this->staticFolderPath($folders[0], $sub_path);
        if (is_dir($path)) {
            $index_path = $this->staticFolderIndexPath($path,
                $settings['index_files']);
            if ($index_path === "") {
                if (!empty($settings['directory_indexes'])) {
                    /* nothing here stands for the folder, so let the
                       page's own resource listing be what shows it */
                    return false;
                }
                $this->sendStaticFolderMissing();
                return true;
            }
            if ($this->redirectStaticFolderSlash()) {
                return true;
            }
            $path = $index_path;
        }
        if (!file_exists($path) || is_dir($path)) {
            $this->sendStaticFolderMissing();
            return true;
        }
        $this->sendStaticFolderFile($data, $path, $group_id, $user_id);
        return true;
    }
    /**
     * staticFolderIndexPath finds which of a page's index files stands for a
     * folder. A page may name several, and the first of them that is in the
     * folder is the one used, so a folder holding either an index.html or an
     * index.htm is served whichever it holds.
     * @param string $folder folder being asked for
     * @param array $index_files names that may stand for a folder, in the order
     *     they are to be tried
     * @return string path of the file that stands for the folder, empty when
     *     none of them is in it
     */
    private function staticFolderIndexPath($folder, $index_files)
    {
        foreach ($index_files as $index_file) {
            $candidate = $folder . "/" . $index_file;
            if (file_exists($candidate) && !is_dir($candidate)) {
                return $candidate;
            }
        }
        return "";
    }
    /**
     * redirectStaticFolderSlash sends a reader who asked for a folder without
     * the closing slash to the same address with one, and says whether it did.
     * A page inside a folder names its stylesheets and its neighbours by where
     * they sit relative to it, and a browser works out where that is from the
     * address it was given: without the closing slash it reads the folder's own
     * name as a file name and looks for everything one level too high. This is
     * what a web server does for the same reason.
     * @return bool whether the reader was sent somewhere, in which case the
     *     caller has nothing left to do
     */
    private function redirectStaticFolderSlash()
    {
        $request_uri = $_SERVER['REQUEST_URI'] ?? "";
        $query_at = strpos($request_uri, "?");
        $path = ($query_at === false) ? $request_uri :
            substr($request_uri, 0, $query_at);
        $query = ($query_at === false) ? "" :
            substr($request_uri, $query_at);
        if ($path === "" || substr($path, -1) === "/") {
            return false;
        }
        $parent = $this->parent;
        $parent->web_site->header("HTTP/1.1 301 Moved Permanently");
        $parent->web_site->header("Location: " . $path . "/" . $query);
        unset($_SESSION['DISPLAY_MESSAGE']);
        \seekquarry\atto\webExit();
        return true;
    }
    /**
     * cleanIndexFileSetting tidies what was typed into a page's Index File
     * setting. Several names may be given, separated by commas, and each is cut
     * back to a plain file name so that none of them can name a place of its
     * own rather than something in the folder being asked for. Each is dealt
     * with on its own: cutting the whole line back at once would keep only what
     * came after the last slash in it and quietly lose every name before that.
     * @param string $setting what was typed into the setting
     * @return string the names that survived, comma separated, in the order
     *     they were given
     */
    private function cleanIndexFileSetting($setting)
    {
        $index_files = [];
        foreach (explode(",", (string)$setting) as $index_file) {
            $index_file = basename(trim($index_file));
            if ($index_file !== "") {
                $index_files[] = $index_file;
            }
        }
        return implode(",", $index_files);
    }

    /**
     * headVarOn says whether one of a page's yes-or-no settings is on. A page's
     * settings are saved as text, so a setting turned off can arrive as the
     * word false rather than as nothing at all, and asking merely whether it is
     * empty would read that word as a yes.
     * @param array $head the page's settings as saved with it
     * @param string $key which setting is being asked about
     * @param bool $default what the setting means when the page does not
     *     mention it
     * @return bool whether the setting is on
     */
    private function headVarOn($head, $key, $default)
    {
        if (!isset($head[$key])) {
            return $default;
        }
        $value = $head[$key];
        if (is_bool($value)) {
            return $value;
        }
        $value = strtolower(trim((string)$value));
        return !($value === "" || $value === "false" || $value === "0");
    }
    /**
     * staticFolderSettings reads a page's static HTML folder settings,
     * understanding the names they used to be saved under. Serving an index
     * page was once all this did and was saved as such, so a page set up that
     * way and not since re-saved is read as a static folder; once the page
     * names the newer setting, that is what is believed, so the setting can be
     * turned back off.
     * @param array $head the page's settings as saved with it
     * @return array whether the page is a static HTML folder, whether a folder
     *     holding no index file of its own lists what is in it, and the names
     *     that may stand for a folder in the order they are to be tried
     */
    protected function staticFolderSettings($head)
    {
        if (isset($head['static_html_folder'])) {
            $is_static = $this->headVarOn($head, 'static_html_folder',
                false);
        } else {
            $is_static = $this->headVarOn($head, 'media_list_index',
                false);
        }
        $index_setting = (string)($head['index_file'] ?? "");
        if (trim($index_setting) === "") {
            $index_setting = (string)($head['media_list_index_file'] ?? "");
        }
        $index_files = [];
        foreach (explode(",", $index_setting) as $index_file) {
            $index_file = basename(trim($index_file));
            if ($index_file !== "") {
                $index_files[] = $index_file;
            }
        }
        if ($index_files === []) {
            $index_files = [self::STATIC_FOLDER_INDEX_FILE];
        }
        return ['static_html_folder' => $is_static,
            'directory_indexes' => $this->headVarOn($head,
            'directory_indexes', false), 'index_files' => $index_files];
    }
    /**
     * staticFolderPath works out which file beneath a page's resource folder
     * was asked for, reading a step back up the way a web server reads one. A
     * step back up from the folder itself has nowhere to go, so it stays there
     * rather than reaching anything outside; what comes back is always within
     * the page's own resources however the path was written.
     * @param string $folder the page's own resource folder
     * @param string $sub_path what was asked for beneath the page
     * @return string path of the file or folder asked for
     */
    private function staticFolderPath($folder, $sub_path)
    {
        $parts = [];
        foreach (explode("/", (string)$sub_path) as $raw_part) {
            /* The file on disk carries its real name with spaces and other
               characters, while the path arrives encoded (a space as "+"
               or "%20"), so each segment is decoded before it is matched
               against a name in the folder, the same as the resource
               server already does for its file-name argument. A segment
               may itself decode to contain a slash (an encoded "%2f"), so
               the decoded text is split again and each piece is checked, so
               an encoded ".." cannot ride inside one segment past the
               checks below. */
            foreach (explode("/", urldecode($raw_part)) as $part) {
                if ($part === "" || $part === ".") {
                    continue;
                }
                if ($part === "..") {
                    array_pop($parts);
                    continue;
                }
                $parts[] = $part;
            }
        }
        return ($parts === []) ? $folder : $folder . "/" .
            implode("/", $parts);
    }
    /**
     * sendStaticFolderMissing tells the reader that a static HTML folder holds
     * nothing by the name they asked for. A folder holding no index file of its
     * own is answered this way when directory indexes are off, so that turning
     * listings off keeps them off rather than falling back to showing one.
     */
    private function sendStaticFolderMissing()
    {
        $parent = $this->parent;
        $parent->web_site->header("HTTP/1.1 404 Not Found");
        $parent->web_site->header("Content-Type: text/plain");
        $body = self::STATIC_FOLDER_MISSING_BODY;
        $parent->web_site->header("Content-Length: " . strlen($body));
        unset($_SESSION['DISPLAY_MESSAGE']);
        e($body);
        \seekquarry\atto\webExit();
    }
    /**
     * sendStaticFolderFile sends one file of a static HTML folder as the whole
     * response. A page served as its own bytes never renders the normal wiki
     * page, so any one-time message waiting to be shown is consumed here;
     * otherwise it would surface later on the next ordinary page. Someone
     * allowed to edit the page is given a fixed edit button in the top opposite
     * corner of an HTML file; every other reader receives the file exactly as
     * stored. The file is sent a block at a time rather than held whole, so a
     * large one does not cost the always-on server its own size in memory.
     * @param array &$data fields for the view, whose PAGE_NAME and CONTROLLER
     *     build the edit link
     * @param string $path file to send
     * @param int $group_id which group the page belongs to
     * @param int $user_id who is asking
     */
    private function sendStaticFolderFile(&$data, $path, $group_id,
        $user_id)
    {
        $parent = $this->parent;
        $mime_type = L\mimeType($path);
        $parent->web_site->header("Content-Type: " . $mime_type);
        unset($_SESSION['DISPLAY_MESSAGE']);
        $edit_overlay = "";
        if (!empty($data["CAN_EDIT"]) &&
            stripos($mime_type, "html") !== false) {
            $edit_overlay = $this->staticFolderEditOverlay($data,
                $group_id, $user_id);
        }
        if ($edit_overlay !== "") {
            /* The file is streamed in blocks and the small edit overlay is
               emitted after it, so an editor viewing a large served file
               never holds the whole file, let alone a second concatenated
               copy, in memory. The content length is the file plus the
               overlay. */
            $parent->web_site->header("Content-Length: " .
                (filesize($path) + strlen($edit_overlay)));
            $parent->web_site->stream(function () use ($path,
                $edit_overlay) {
                $handle = fopen($path, "rb");
                try {
                    while (!feof($handle)) {
                        $bytes = fread($handle, C\RESOURCE_STREAM_BLOCK_LEN);
                        if ($bytes === false || $bytes === "") {
                            break;
                        }
                        yield $bytes;
                    }
                } finally {
                    fclose($handle);
                }
                yield $edit_overlay;
            });
            \seekquarry\atto\webExit();
        }
        $parent->web_site->header("Content-Length: " . filesize($path));
        $parent->web_site->stream(function () use ($path) {
            $handle = fopen($path, "rb");
            try {
                while (!feof($handle)) {
                    $bytes = fread($handle, C\RESOURCE_STREAM_BLOCK_LEN);
                    if ($bytes === false || $bytes === "") {
                        break;
                    }
                    yield $bytes;
                }
            } finally {
                fclose($handle);
            }
        });
        \seekquarry\atto\webExit();
    }
    /**
     * staticFolderEditOverlay builds the edit button laid over an HTML file of
     * a static HTML folder for someone allowed to edit the page. The file is
     * served as itself with no wiki page around it, so this is the only way
     * back to editing the page from what a reader sees.
     * @param array &$data fields for the view, whose PAGE_NAME and CONTROLLER
     *     build the link
     * @param int $group_id which group the page belongs to
     * @param int $user_id who is asking, whose token the link carries
     * @return string HTML of the button
     */
    private function staticFolderEditOverlay(&$data, $group_id, $user_id)
    {
        $parent = $this->parent;
        $edit_url = htmlentities(B\wikiUrl($data['PAGE_NAME'], true,
            $data['CONTROLLER'], $group_id) . C\p('CSRF_TOKEN') . "=" .
            $parent->generateCSRFToken($user_id) . "&arg=edit");
        list($edit_label, $edit_glyph, ) = $parent->view("group")
            ->helper("iconlink")->icon_possibilities['edit'];
        return "<a href=\"" . $edit_url . "\" role=\"button\" " .
            "aria-label=\"" . $edit_label . "\" style=\"position:fixed;" .
            "top:0;right:0;z-index:2147483647;margin:0.5em;" .
            "padding:0.35em 0.5em;background:#f2f2f2;" .
            "border:1px solid #888;border-radius:0.4em;" .
            "box-shadow:0 1px 3px rgba(0,0,0,0.35);" .
            "font:bold 1.25rem sans-serif;color:#222;" .
            "text-decoration:none;white-space:nowrap;line-height:1;\">" .
            $edit_glyph . "</a>";
    }

    /**
     * paragraphCountInMark gives the number a place's mark ends with, which
     * says how much of what it holds to show. A mark written before that
     * setting existed ends with no number and gets the usual.
     * @param string $body the page holding the mark
     * @param string $found the beginning of the mark, as it was matched
     * @return int the number, or the usual where the mark gives none
     */
    public static function paragraphCountInMark($body, $found)
    {
        $at = strpos($body, $found);
        if ($at === false) {
            return self::LEAD_PARAGRAPHS;
        }
        $ends = strpos($body, "}]", $at);
        if ($ends === false) {
            $ends = strpos($body, "}}", $at);
        }
        if ($ends === false) {
            return self::LEAD_PARAGRAPHS;
        }
        $said = substr($body, $at, $ends - $at);
        $parts = explode("|", $said);
        $last = trim(end($parts));
        return (is_numeric($last) && $last > 0) ? (int)$last :
            self::LEAD_PARAGRAPHS;
    }

    /**
     * leadStoryMarkup gives the markup that stands where a lead story tag
     * stood: the beginning of the article named, with its title and a way
     * through to the rest of it. A reader should be able to tell from the front
     * page whether a story is for them, which a bare link does not say. A name
     * that leads nowhere is kept and shown as itself, since a writer may lay a
     * front page out before writing the pages it points at, and losing the name
     * on every visit made that impossible.
     * @param object $parent controller that called this
     * @param object $group_model model used to look the page 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 the links carry their token
     * @param string $named which page the place holds
     * @param string $classes the classes the place is drawn with
     * @param int $paragraphs how many paragraphs of the story to show
     * @return string markup to stand where the tag stood
     */
    protected function leadStoryMarkup($parent, $group_model, $group_id,
        $data, $user_id, $named, $classes, $paragraphs = 0)
    {
        $wiki_model = $parent->model("wiki");
        $named = trim($named);
        $paragraphs = ($paragraphs > 0) ? $paragraphs :
            self::LEAD_PARAGRAPHS;
        $opening = "<div class='lead-story " . $classes . "'>";
        $page_id = $wiki_model->getPageId($group_id, $named,
            $data['CURRENT_LOCALE_TAG']);
        if (empty($page_id)) {
            return $opening . "<p class='lead-story-missing'>" .
                tl('social_component_lead_story_missing', $named) .
                "</p></div>";
        }
        /* The page itself is wanted, not just what is recorded about
           it: asking by number gives the row without its text, so the
           story came back empty and only the way through to it showed. */
        /* The page itself is wanted, not just what is recorded about it
           and not the markup a writer typed: asking by number gives the
           row without its text, and asking to edit gives the markup, so
           the story came back either empty or unread. */
        $held = $wiki_model->getPageInfoByName($group_id, $named,
            $data['CURRENT_LOCALE_TAG'], 'read');
        list($head, $body) = WikiParser::parsePageHeadVars(
            $held['PAGE'] ?? "", true);
        $title = trim($head['title'] ?? "");
        if ($title === "") {
            $title = $named;
        }
        $csrf_token = $parent->generateCSRFToken($user_id);
        $page_url = htmlentities(B\wikiUrl($named, true,
            $data['CONTROLLER'], $group_id)) . C\p('CSRF_TOKEN') . '=' .
            $csrf_token;
        /* The story's own beginning stands for it: its heading, its
           pictures and whatever else it opens with, down to the number of
           paragraphs the place was set to show. Its thumbnail is not
           shown here -- that belongs to a category's list, where a title
           and an abstract need something beside them -- and its name is
           not repeated as a link above, since the way through to it is
           the one line at the end. */
        /* A story shown on a front page opens the way it opens on its own
           page: its title, the banner with what it shows and who holds
           it, then who wrote it, and then as much of the story as the
           place was set to show. */
        $markup = $opening . "<h2 class='article-title'>" . $title .
            "</h2>";
        $banner = $wiki_model->getGroupPageIconUrl($csrf_token, $group_id,
            $page_id);
        if (!empty($banner)) {
            $markup .= "<div class='article-banner'>" .
                "<img class='article-banner-image' src='" . $banner .
                "' alt='" . $title . "'>";
            if (trim($head['banner_caption'] ?? "") !== "") {
                $markup .= "<span class='article-banner-caption'>" .
                    $head['banner_caption'] . "</span>";
            }
            if (trim($head['banner_rights'] ?? "") !== "") {
                $markup .= "<span class='article-banner-rights'>" .
                    $head['banner_rights'] . "</span>";
            }
            $markup .= "</div>";
        }
        if (trim($head['author'] ?? "") !== "") {
            $markup .= "<p class='article-author'><b>" . $head['author'] .
                "</b></p>";
        }
        if (trim($head['article_date'] ?? "") !== "") {
            $when_said = (($head['article_date_kind'] ?? "date") ==
                "updated") ? tl('wiki_element_article_updated') :
                tl('wiki_element_article_date');
            /* The word already carries its colon, so none is added. */
            $markup .= "<p class='article-date'>" . $when_said . " " .
                $head['article_date'] . "</p>";
        }
        $markup .= self::htmlUpToParagraphCount($body, $paragraphs);
        $markup .= "<p class='lead-story-more'><a href='" . $page_url .
            "'>" . tl('social_component_lead_story_more') . "</a></p>";
        return $markup . "</div>";
    }
    /**
     * htmlUpToParagraphCount gives the first paragraphs of a page, which is
     * what a front page shows of a story it leads with.
     * @param string $body the page as it is kept
     * @param int $wanted how many paragraphs to count before stopping
     * @return string the opening paragraphs
     */
    public static function htmlUpToParagraphCount($body,
        $wanted = self::LEAD_PARAGRAPHS)
    {
        /* Everything the story opens with is kept, headings and pictures
           and all, and only paragraphs are counted towards the number
           wanted: a story that begins with a heading and a photograph has
           not yet said anything, so counting those would cut it off
           before it started. */
        $seen = 0;
        $at = 0;
        $length = strlen($body);
        while ($seen < $wanted) {
            $opens = stripos($body, "<p", $at);
            if ($opens === false) {
                return $body;
            }
            $closes = stripos($body, "</p>", $opens);
            if ($closes === false) {
                return $body;
            }
            $at = $closes + strlen("</p>");
            $seen++;
        }
        if ($seen < 1) {
            return "<p>" . mb_substr(trim(strip_tags($body)), 0,
                self::LEAD_LETTERS) . "</p>";
        }
        return self::closedOff(substr($body, 0, $at));
    }

    /**
     * closedOff closes any element a cut left open. Stopping partway through a
     * page can fall inside something the page opened, such as a box holding a
     * floated picture, and what is handed back then ends mid-element. A browser
     * given that treats whatever follows as sitting inside it, so the next
     * place on a front page ended up nested in the one before.
     * @param string $part the beginning of a page, cut at some point
     * @return string the same with every element it left open closed
     */
    public static function closedOff($part)
    {
        $standing = [];
        $found = [];
        preg_match_all("/<(\\/?)([a-zA-Z][a-zA-Z0-9]*)\\b[^>]*(\\/?)>/",
            $part, $found, PREG_SET_ORDER);
        foreach ($found as $one) {
            $name = strtolower($one[2]);
            if (in_array($name, self::CLOSES_ITSELF) || $one[3] == "/") {
                continue;
            }
            if ($one[1] == "/") {
                $at = array_search($name, array_reverse($standing, true));
                if ($at !== false) {
                    unset($standing[$at]);
                    $standing = array_values($standing);
                }
                continue;
            }
            $standing[] = $name;
        }
        foreach (array_reverse($standing) as $name) {
            $part .= "</" . $name . ">";
        }
        return $part;
    }
    /**
     * categoryNamesMarkup gives the markup for a place holding the names of
     * every category a group files its articles under. Each name is a way into
     * the pages of the group narrowed to that category, so a reader can find
     * their way from a front page to everything filed one way.
     * @param object $parent controller that called this
     * @param object $group_model model used to look the names up
     * @param int $group_id which group the names belong to
     * @param array $data fields from the controller
     * @param int $user_id who is reading, so the links carry their token
     * @param string $classes the classes the place is drawn with
     * @return string markup to stand where the mark stood
     */
    protected function categoryNamesMarkup($parent, $group_model, $group_id,
        $data, $user_id, $classes)
    {
        $wiki_model = $parent->model("wiki");
        $kept = $wiki_model->getGroupPageSettings($group_id);
        $names = array_unique(array_merge($kept['categories'] ?? [],
            $group_model->getGroupCategories($group_id)));
        sort($names);
        $markup = "<div class='category-names " . trim($classes) . "'>";
        if (empty($names)) {
            return $markup . "<p class='category-names-empty'>" .
                tl('social_component_no_categories') . "</p></div>";
        }
        $csrf_token = $parent->generateCSRFToken($user_id);
        $markup .= "<ul class='category-names-items'>";
        foreach ($names as $one) {
            /* The name is carried as a field rather than as part of the
               path: a name written into the path after "pages" reaches
               the routing but not the controller, where the one already
               used for a category's own page arrives. */
            $markup .= "<li class='category-names-item'><a href='" .
                htmlentities(B\wikiUrl("pages/" . $one, true,
                $data['CONTROLLER'] ?? "group", $group_id)) .
                C\p('CSRF_TOKEN') . '=' . $csrf_token . "'>" . $one .
                "</a></li>";
        }
        return $markup . "</ul></div>";
    }

    /**
     * categoryListSettings reads the settings a category list was asked for
     * /** Reads the settings a category list was asked for    /** Reads the
     * settings a category list was asked for    /** Reads the settings a
     * category list was asked for    /** Reads the settings a category list was
     * asked for    /** Reads the settings a category list was asked for, given
     * by name so that a writer may give any of them in any order and leave the
     * rest alone. What was written as a bare list of classes still reads, so
     * pages already written go on working.
     * @param object $parent controller that called this, used to clean what was
     *     typed
     * @param string $said what the tag carried after the category's name
     * @return array the classes, how many to show, in what order, and which
     *     page of the results is wanted
     */
    private function categoryListSettings($parent, $said)
    {
        $settings = ["classes" => "", "num" => self::CATEGORY_LIST_LENGTH,
            "sort" => "modified_desc", "page" => 1, "heading" => "",
            "scroll" => "yes", "shape" => "standard", "filter" => ""];
        $named = false;
        foreach (explode("|", $said) as $part) {
            $at = strpos($part, "=");
            if ($at === false) {
                continue;
            }
            $name = trim(strtolower(substr($part, 0, $at)));
            $value = trim(substr($part, $at + 1));
            if (!isset($settings[$name])) {
                continue;
            }
            $named = true;
            $settings[$name] = in_array($name, ["classes", "sort",
                "heading", "scroll", "shape", "filter"]) ?
                $parent->clean($value, "string") :
                max(1, $parent->clean($value, "int"));
        }
        if (!$named) {
            $settings["classes"] = $parent->clean(trim($said), "string");
        }
        if (!in_array($settings["sort"], ["modified_desc", "modified_asc",
            "name_asc", "name_desc"])) {
            $settings["sort"] = "modified_desc";
        }
        return $settings;
    }
    /**
     * categoryListMarkup gives the markup that stands where one category list
     * tag stood: the pages filed under the category named, each with its
     * thumbnail, its title and its description, or a note saying the category
     * holds nothing yet. Where more pages are filed than were asked for, a way
     * on to the rest follows the list. A name the group does not know holds
     * nothing and gets that note too. Looking such a name up used to leave the
     * search with no category to narrow by, so a page asking for a category
     * that did not exist was shown every page in the group instead of none.
     * @param object $parent controller that called this, used for the reader's
     *     token
     * @param object $group_model model used to look the pages up
     * @param int $group_id which group the page belongs to
     * @param array $data fields from the controller, holding the language
     * @param int $user_id who is reading, so the links carry their token
     * @param string $category the category the tag names
     * @param string $said what the tag carried after the category's name
     * @return string markup to stand where the tag stood
     */
    protected function categoryListMarkup($parent, $group_model, $group_id,
        $data, $user_id, $category, $said)
    {
        $wiki_model = $parent->model("wiki");
        $asked = $this->categoryListSettings($parent, $said);
        /* A list may be shown three ways: with a picture beside each
           title and its abstract beneath, without the picture, or as
           titles alone. Which it is rides on the list itself so the
           styling can follow it. */
        $shape = in_array($asked["shape"], ["standard", "compact",
            "minimal"]) ? $asked["shape"] : "standard";
        $classes = "category-list category-list-" . $shape . " " .
            $asked["classes"];
        $opening = "<div class='" . $classes . "' data-category='" .
            $category . "'>";
        $empty = $opening . "<p class='category-list-empty'>" . tl(
            'social_component_no_article_in_category', $category) .
            "</p></div>";
        /* A category may name a group before it, as group@category, so a
           front page in one group can collect what another group files
           under a name. The group named is the one asked, and where it
           names none the page's own group stands. */
        list($group_id, $category) = self::groupAndCategory($category,
            $group_id, $group_model);
        if ($group_id <= 0) {
            return $empty;
        }
        if ($group_model->getRelationshipId($category) === false) {
            return $empty;
        }
        $skip = ($asked["page"] - 1) * $asked["num"];
        list($num, $found_pages) = $wiki_model->getPageList($group_id,
            $data['CURRENT_LOCALE_TAG'], $asked["filter"], $asked["sort"],
            $skip, $asked["num"], $category);
        if ($num <= 0) {
            return $empty;
        }
        $csrf_token = $parent->generateCSRFToken($user_id);
        $token_string = C\p('CSRF_TOKEN') . '=' . $csrf_token;
        $markup = str_replace("<div class='", "<div data-category-page='" .
            $asked["page"] . "' data-category-num='" . $asked["num"] .
            "' data-category-sort='" . $asked["sort"] . "' class='",
            $opening);
        /* A place may be set to head its list with the category's name,
           which a front page of several sections wants and a single list
           does not. The heading stands inside the place rather than
           before it: outside, it is a block of its own between two
           places, and a row of cards broke into a column at every
           heading. */
        if (trim($asked["heading"]) !== "") {
            $markup .= "<h2 class='category-list-name'>" .
                trim($asked["heading"]) . "</h2>";
        }
        $markup .= "<ul class='category-list-items'>";
        foreach ($found_pages as $item) {
            $header = $item['HEADER'];
            $page_title = (empty($header['title'])) ?
                $item['SHOW_PAGE_NAME'] : $header['title'];
            $page_url = htmlentities(B\wikiUrl($item['PAGE_NAME'], true,
                $data['CONTROLLER'], $group_id)) . $token_string;
            $markup .= "<li class='category-list-item'>";
            if ($shape == "standard") {
                $markup .= "<a class='category-list-icon' href='" .
                    $page_url . "'><img src='" .
                    $wiki_model->getGroupPageIconUrl($csrf_token,
                    $group_id, $item['ID']) . "' alt='" . $page_title .
                    "'></a>";
            }
            $markup .= "<a class='category-list-title' href='" . $page_url .
                "'>" . $page_title . "</a>";
            if ($shape != "minimal") {
                $markup .= "<p class='category-list-description'>" .
                    $item['SHOW_DESCRIPTION'] . "</p>";
            }
            $markup .= "</li>";
        }
        $markup .= "</ul>";
        /* Where more are filed than this page of the list holds, the
           list says so on itself, and the reader's browser fetches the
           next page as they reach the end. The way on beneath it stays
           for anyone whose browser is not running scripts. */
        if ($asked["scroll"] != "no" &&
            $num > $asked["page"] * $asked["num"]) {
            $markup = str_replace("<ul class='category-list-items'>",
                "<ul class='category-list-items' data-category-more='1'>",
                $markup);
        }
        $markup .= $this->categoryListMoreMarkup($data, $group_id,
            $category, $asked, $num, $token_string);
        return $markup . "</div>";
    }
    /**
     * categoryListMoreMarkup gives the way on to the rest of a category, where
     * more pages are filed under it than one page of the list holds. A reader
     * of a busy section would otherwise see the first few and have no way past
     * them.
     * @param array $data fields from the controller
     * @param int $group_id which group the page belongs to
     * @param string $category the category being listed
     * @param array $asked the settings the list was asked for
     * @param int $num how many pages are filed under the category
     * @param string $token_string the reader's token, as a query field
     * @return string markup for the way on, or nothing where the whole category
     *     already fits
     */
    private function categoryListMoreMarkup($data, $group_id, $category,
        $asked, $num, $token_string)
    {
        $pages = (int)ceil($num / $asked["num"]);
        if ($pages < 2) {
            return "";
        }
        $its_page = htmlentities(B\wikiUrl("pages/" . $category, true,
            $data['CONTROLLER'], $group_id)) . $token_string;
        $markup = "<p class='category-list-more'>";
        if ($asked["page"] > 1) {
            $markup .= "<a href='" . $its_page . "&amp;page=" .
                ($asked["page"] - 1) . "'>" .
                tl('social_component_category_earlier') . "</a> ";
        }
        $markup .= "<a href='" . $its_page . "'>" .
            tl('social_component_category_all', $num) . "</a>";
        return $markup . "</p>";
    }
    /**
     * applyGroupAppearance gives a page the look its group keeps for it: the
     * theme it is drawn with and the pages to stand above and below. A page
     * that names its own goes on using that, so the group's are what a page
     * falls back to rather than what it is held to. Where the group does not
     * let a page name its own, the group's are used whatever the page was saved
     * with, since a page saved before the group changed its mind would
     * otherwise keep a look no longer on offer. The view's own copy of the head
     * is written as well as the one the page is drawn from, because the theme
     * is read from the view when the surrounding page is laid out.
     * @param array &$data fields for the view, holding the page's head
     * @param object $view the view the page is drawn with
     * @param array $group the group the page belongs to
     */
    private function applyGroupAppearance(&$data, $view, $group)
    {
        if (empty($data["HEAD"]) || !is_array($data["HEAD"])) {
            $data["HEAD"] = [];
        }
        $customizable = !empty($group['PAGE_CUSTOMIZE_ALLOWED']);
        $from_group = ['page_theme' => $group['GROUP_THEME'] ?? "",
            'page_header' => $group['PAGE_HEADER'] ?? "",
            'page_footer' => $group['PAGE_FOOTER'] ?? ""];
        $page_id = $data["PAGE_ID"] ?? "";
        foreach ($from_group as $key => $said) {
            if ($said === "") {
                continue;
            }
            if (!$customizable || trim($data["HEAD"][$key] ?? "") === "") {
                $data["HEAD"][$key] = $said;
                if (!empty($page_id) && isset($view->head_objects[$page_id])) {
                    $view->head_objects[$page_id][$key] = $said;
                }
            }
        }
    }

    /**
     * categoriesInBody gives the categories named by tags in an article's own
     * words. A writer may type a tag rather than use the picker, and either way
     * the page should be filed.
     * @param string $page the article as the writer left it
     * @return array the names its tags give, in the order they appear
     */
    public static function categoriesInBody($page)
    {
        $names = [];
        $found = [];
        preg_match_all("/\\{\\{category\\|([^}]*)\\}\\}/i", $page,
            $found);
        foreach ($found[1] ?? [] as $one) {
            $one = trim($one);
            if ($one !== "" && !in_array($one, $names)) {
                $names[] = $one;
            }
        }
        return $names;
    }
}
