SeekquarrySeekquarry - Yioop Repo - Seekquarry

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

use seekquarry\yioop\configs as C;

/**
 * V10ResourceFolderMigration moves the folders of app/resources from the
 * pre-version 10 layout to the version 10 layout ResourceFolders gives,
 * and renames personal groups from Personal$<user id> to
 * <user name>$Personal, which the version 10 layout names their folders
 * from. Database upgrade 134, the first upgrade of version 10 to touch
 * app/resources, runs it.
 *
 * In the pre-version 10 layout every page, post and user folder was
 * app/resources/<prefix>/<hash>, with <hash> the crawlHash of the group
 * id, the page id or "post" and a post id, and the site's AUTH_KEY, or
 * of "user", a user id and AUTH_KEY, and <prefix> its first three
 * letters; thumbnails were in t<hash> beside it, or, for the oldest
 * folders, in the folder of the crawlHash of "thumb", the ids and
 * AUTH_KEY. The feed pictures were in t followed by the hash of "group",
 * the Public group's id and AUTH_KEY. The site's appearance files were
 * directly in app/resources, and a host's in app/resources/<host>.
 *
 * run reads the rows of GROUP_PAGE, GROUP_ITEM and USERS once, keeping
 * only the hashes it finds on disk, so its memory is about the number of
 * folders, and its time is about the number of rows plus the number of
 * folders, each moved by one rename. Every step is safe to repeat: what
 * has been moved is not found again, so a migration stopped part way is
 * finished by running it again. Database upgrade 134 runs it, and so does
 * php ConfigureTool.php migrate-pre-v10-resources, which a site with many
 * posts can run before upgrading so the upgrade, made during a web
 * request, finds nothing left to do. All code that knows the pre-version
 * 10 layout is in this class, so the classes used day to day know only
 * the version 10 layout.
 */
class V10ResourceFolderMigration
{
    /**
     * UNMIGRATED_FOLDER is the folder directly in app/resources that the
     * pre-version 10 folders no page, post or user names are moved to, for
     * the site's administrator to look at, rather than being deleted.
     */
    const UNMIGRATED_FOLDER = "unmigrated";
    /**
     * OLD_PERSONAL_GROUP_PREFIX began the name of a user's personal group
     * before version 10, followed by the user's id, as in Personal$7.
     * renamePersonalGroups renames such a group to the user's name followed
     * by PERSONAL_GROUP_SUFFIX.
     */
    const OLD_PERSONAL_GROUP_PREFIX = 'Personal$';
    /**
     * OLD_HASH_PATTERN matches the name of a folder of the pre-version 10
     * layout: a crawlHash of 11 letters, digits, - or _, with a t before it
     * for a thumbnail folder.
     */
    const OLD_HASH_PATTERN = '/^t?[A-Za-z0-9_\-]{11}$/';
    /**
     * OLD_HASH_LEN is the length of the hash in a folder name of the
     * pre-version 10 layout.
     */
    const OLD_HASH_LEN = 11;
    /**
     * OLD_PREFIX_LEN is the length of the names of the prefix folders of
     * the pre-version 10 layout.
     */
    const OLD_PREFIX_LEN = 3;
    /**
     * KEPT_TOP_FILES are the files directly in app/resources that stay
     * there: the lists the recommendation and description jobs read, which
     * are never served.
     */
    const KEPT_TOP_FILES = ["recommendation.txt", "needs_descriptions.txt"];
    /**
     * KEPT_TOP_FOLDERS are the folders directly in app/resources that the
     * migration leaves where they are.
     */
    const KEPT_TOP_FOLDERS = ["needs_thumbs", "site", "users", "domains",
        self::UNMIGRATED_FOLDER];
    /**
     * OS_FOLDER_FILES are the files an operating system's file browser
     * puts in a folder it has shown: .DS_Store by macOS's Finder,
     * Thumbs.db and desktop.ini by Windows Explorer. A prefix folder
     * holding only these once its hashed folders have moved counts as
     * empty and is deleted.
     */
    const OS_FOLDER_FILES = [".DS_Store", "Thumbs.db", "desktop.ini"];
    /**
     * REPORTED_NAMES is how many of the names in a prefix folder that is
     * kept the report gives, so the reason it was kept can be seen without
     * a line running on for a whole folder.
     */
    const REPORTED_NAMES = 5;
    /**
     * $db is the database the rows are read from and the groups renamed
     * in.
     * @var object
     */
    public $db;
    /**
     * $resource_path is the app/resources folder being migrated.
     * @var string
     */
    public $resource_path;
    /**
     * $auth_key is the AUTH_KEY the pre-version 10 folder names were made
     * with.
     * @var string
     */
    public $auth_key;
    /**
     * $log_function is called with each line of the report as it is made,
     * if not null.
     * @var callable|null
     */
    public $log_function;
    /**
     * $counts holds counts of what was done, by kind, for the report.
     * @var array
     */
    public $counts = [];
    /**
     * $old_folders maps each pre-version 10 hash found on disk to the
     * paths of its folder and thumbnail folders.
     * @var array
     */
    public $old_folders = [];
    /**
     * $new_folders maps each pre-version 10 hash to its version 10 folder:
     * the folder of the page or post it belongs to, which holds the page
     * and thumbs folders; the feeds folder for the feed pictures; and the
     * user's folder for a user.
     * @var array
     */
    public $new_folders = [];
    /**
     * $hash_kinds maps each pre-version 10 hash to PAGE_KIND, THUMBS_KIND
     * or WHOLE_KIND, which says where in its version 10 folder the folder
     * named by the hash goes.
     * @var array
     */
    public $hash_kinds = [];
    /**
     * PAGE_KIND marks a pre-version 10 hash of "group" and the ids: the
     * folder named by it holds a page's or post's files and goes to the
     * page folder, and the folder named by t and it goes to the thumbs
     * folder.
     */
    const PAGE_KIND = "page";
    /**
     * THUMBS_KIND marks a pre-version 10 hash that names only thumbnails,
     * with or without a t before it: that of "thumb" and the ids, and that
     * of the path a redirect.txt gives.
     */
    const THUMBS_KIND = "thumbs";
    /**
     * WHOLE_KIND marks a pre-version 10 hash whose folder's contents
     * become the version 10 folder itself: a user's folder, and the feed
     * pictures.
     */
    const WHOLE_KIND = "whole";
    /**
     * __construct sets up a migration of one app/resources folder.
     *
     * @param object $db the Yioop database
     * @param string $resource_path the app/resources folder; "" for the
     *      work directory's
     * @param string $auth_key the AUTH_KEY the pre-version 10 names were
     *      made with; "" for the site's own
     * @param callable|null $log_function called with each report line
     */
    public function __construct($db, $resource_path = "", $auth_key = "",
        $log_function = null)
    {
        $this->db = $db;
        $this->resource_path = ResourceFolders::basePath($resource_path);
        $this->auth_key = ($auth_key === "") ? C\p('AUTH_KEY') : $auth_key;
        $this->log_function = $log_function;
    }
    /**
     * run does the whole migration, as the class docblock says, and gives
     * what it did.
     *
     * @return array kind => count of what was done, and "seconds" => how
     *      long it took
     */
    public function run()
    {
        $start = microtime(true);
        $this->counts = ["personal groups renamed" => 0,
            "groups renamed to free a personal group name" => 0,
            "folders moved" => 0, "links remade" => 0,
            "folders kept in unmigrated" => 0, "site files moved" => 0,
            "host folders moved" => 0, "paths rewritten" => 0,
            "prefix folders kept" => 0];
        $this->renamePersonalGroups();
        if (is_dir($this->resource_path)) {
            $this->findOldFolders();
            if (!empty($this->old_folders)) {
                $this->mapOldFolders();
                $moved = $this->moveOldFolders();
                $this->rewriteStoredPaths($moved);
            }
            $this->removeEmptyPrefixFolders();
            $this->moveSiteFiles();
        }
        $this->counts["seconds"] = round(microtime(true) - $start, 2);
        foreach ($this->counts as $kind => $count) {
            $this->log("$kind: $count");
        }
        return $this->counts;
    }
    /**
     * renamePersonalGroups renames each group named
     * OLD_PERSONAL_GROUP_PREFIX and a user id, owned by that user, to the
     * user's name followed by PERSONAL_GROUP_SUFFIX. A group named in the
     * pre-version 10 form but owned by someone else, which another user
     * made before that user had a personal group, and any group whose name
     * ends in PERSONAL_GROUP_SUFFIX but is not its owner's personal group,
     * has every $ in its name replaced by _, so it is an ordinary group
     * and cannot take a user's messages. This is done first, as the
     * version 10 folder names of personal groups' pages and posts come
     * from their version 10 names.
     */
    public function renamePersonalGroups()
    {
        $db = $this->db;
        $user_names = [];
        $result = $db->execute("SELECT USER_ID, USER_NAME FROM USERS");
        while ($result && ($row = $db->fetchArray($result))) {
            $user_names[$row['USER_ID']] = mb_strtolower($row['USER_NAME']);
        }
        $groups = [];
        $result = $db->execute("SELECT GROUP_ID, GROUP_NAME, OWNER_ID " .
            "FROM SOCIAL_GROUPS");
        while ($result && ($row = $db->fetchArray($result))) {
            if (str_contains($row['GROUP_NAME'], '$')) {
                $groups[] = $row;
            }
        }
        $prefix_len = strlen(self::OLD_PERSONAL_GROUP_PREFIX);
        foreach ($groups as $group) {
            $name = $group['GROUP_NAME'];
            $owner_name = $user_names[$group['OWNER_ID']] ?? false;
            $is_old_personal = str_starts_with($name,
                self::OLD_PERSONAL_GROUP_PREFIX) &&
                ctype_digit(substr($name, $prefix_len));
            if ($owner_name !== false &&
                $name === $owner_name . C\PERSONAL_GROUP_SUFFIX) {
                continue;
            }
            if ($is_old_personal && $owner_name !== false &&
                substr($name, $prefix_len) == $group['OWNER_ID']) {
                $this->freeGroupName($owner_name . C\PERSONAL_GROUP_SUFFIX,
                    $groups);
                $this->renameGroup($group['GROUP_ID'],
                    $owner_name . C\PERSONAL_GROUP_SUFFIX);
                $this->counts["personal groups renamed"]++;
            } else if ($is_old_personal ||
                str_ends_with($name, C\PERSONAL_GROUP_SUFFIX)) {
                $this->renameGroup($group['GROUP_ID'],
                    $this->ordinaryGroupName($name, $group['GROUP_ID']));
                $this->counts["groups renamed to free a personal group " .
                    "name"]++;
            }
        }
    }
    /**
     * freeGroupName renames a group other than a personal group that
     * holds a name a personal group is about to take, as
     * ordinaryGroupName says.
     *
     * @param string $name the personal group name to free
     * @param array &$groups the groups with a $ in their names, as
     *      renamePersonalGroups read them; the renamed one is updated
     */
    public function freeGroupName($name, &$groups)
    {
        foreach ($groups as $index => $group) {
            if ($group['GROUP_NAME'] === $name) {
                $new_name = $this->ordinaryGroupName($name,
                    $group['GROUP_ID']);
                $this->renameGroup($group['GROUP_ID'], $new_name);
                $groups[$index]['GROUP_NAME'] = $new_name;
                $this->counts["groups renamed to free a personal group " .
                    "name"]++;
            }
        }
    }
    /**
     * ordinaryGroupName gives the name a group that must not keep a $ is
     * renamed to: its name with each $ replaced by _ and, should that be
     * taken, the group's id added.
     *
     * @param string $name the group's name
     * @param int $group_id the group's id
     * @return string the new name
     */
    public function ordinaryGroupName($name, $group_id)
    {
        $new_name = str_replace('$', '_', $name);
        $result = $this->db->execute("SELECT GROUP_ID FROM SOCIAL_GROUPS " .
            "WHERE GROUP_NAME = ?", [$new_name]);
        if ($result && $this->db->fetchArray($result)) {
            $new_name .= "_" . $group_id;
        }
        return $new_name;
    }
    /**
     * renameGroup changes a group's name in SOCIAL_GROUPS and reports it.
     *
     * @param int $group_id the group to rename
     * @param string $new_name its new name
     */
    public function renameGroup($group_id, $new_name)
    {
        $this->db->execute("UPDATE SOCIAL_GROUPS SET GROUP_NAME = ? " .
            "WHERE GROUP_ID = ?", [$new_name, $group_id]);
        $this->log("group $group_id renamed to $new_name");
    }
    /**
     * findOldFolders lists, into old_folders, the folders and links of the
     * pre-version 10 layout on disk, grouped by hash. It reads only the
     * names in app/resources and in its three-letter prefix folders, never
     * the names inside a page's folder.
     */
    public function findOldFolders()
    {
        $this->old_folders = [];
        foreach (scandir($this->resource_path) ?: [] as $prefix) {
            $prefix_path = $this->resource_path . "/$prefix";
            if (strlen($prefix) != self::OLD_PREFIX_LEN ||
                !preg_match('/^[A-Za-z0-9_\-]+$/', $prefix) ||
                is_link($prefix_path) || !is_dir($prefix_path)) {
                continue;
            }
            foreach (scandir($prefix_path) ?: [] as $name) {
                $path = "$prefix_path/$name";
                $hash = (strlen($name) > self::OLD_HASH_LEN) ?
                    substr($name, 1) : $name;
                if (preg_match(self::OLD_HASH_PATTERN, $name) &&
                    str_starts_with($hash, $prefix) &&
                    (is_link($path) || is_dir($path))) {
                    $this->old_folders[$hash][] = $path;
                }
            }
        }
    }
    /**
     * mapOldFolders works out, into new_folders, the version 10 folder of
     * each pre-version 10 hash found on disk, from the rows of GROUP_PAGE,
     * GROUP_ITEM and USERS and from the feed pictures' hash. Before
     * version 10, a redirected page's thumbnails were kept under the hash
     * of the path its redirect.txt names; they go to that page's thumbs
     * folder.
     */
    public function mapOldFolders()
    {
        $db = $this->db;
        $key = $this->auth_key;
        $group_names = [];
        $result = $db->execute("SELECT GROUP_ID, GROUP_NAME FROM " .
            "SOCIAL_GROUPS");
        while ($result && ($row = $db->fetchArray($result))) {
            $group_names[$row['GROUP_ID']] = $row['GROUP_NAME'];
        }
        $user_names = [];
        $result = $db->execute("SELECT USER_ID, USER_NAME FROM USERS");
        while ($result && ($row = $db->fetchArray($result))) {
            $user_names[$row['USER_ID']] = $row['USER_NAME'];
            $this->mapHash(crawlHash("user" . $row['USER_ID'] . $key),
                ResourceFolders::userPath($row['USER_NAME'],
                $this->resource_path), self::WHOLE_KIND);
        }
        $result = $db->execute("SELECT ID, GROUP_ID, TITLE, LOCALE_TAG " .
            "FROM GROUP_PAGE");
        while ($result && ($row = $db->fetchArray($result))) {
            if (!isset($group_names[$row['GROUP_ID']])) {
                continue;
            }
            $ids = $row['GROUP_ID'] . $row['ID'];
            $new = ResourceFolders::wikiPagePath(
                $group_names[$row['GROUP_ID']], $row['LOCALE_TAG'],
                $row['TITLE'], $this->resource_path);
            $this->mapHash(crawlHash("group" . $ids . $key), $new,
                self::PAGE_KIND);
            $this->mapHash(crawlHash("thumb" . $ids . $key), $new,
                self::THUMBS_KIND);
        }
        $result = $db->execute("SELECT ID, GROUP_ID, USER_ID, PUBDATE " .
            "FROM GROUP_ITEM");
        while ($result && ($row = $db->fetchArray($result))) {
            if (!isset($group_names[$row['GROUP_ID']])) {
                continue;
            }
            $ids = $row['GROUP_ID'] . "post" . $row['ID'];
            $new = ResourceFolders::postPath($group_names[$row['GROUP_ID']],
                $user_names[$row['USER_ID']] ?? "", $row['PUBDATE'],
                $this->resource_path);
            $this->mapHash(crawlHash("group" . $ids . $key), $new,
                self::PAGE_KIND);
            $this->mapHash(crawlHash("thumb" . $ids . $key), $new,
                self::THUMBS_KIND);
        }
        $this->mapHash(crawlHash("group" . C\PUBLIC_GROUP_ID . $key),
            ResourceFolders::feedsPath($this->resource_path),
            self::WHOLE_KIND);
        foreach ($this->new_folders as $hash => $new) {
            if ($this->hash_kinds[$hash] != self::PAGE_KIND) {
                continue;
            }
            foreach ($this->old_folders[$hash] as $path) {
                if (!is_link($path) && is_file("$path/redirect.txt")) {
                    $target = trim(file_get_contents("$path/redirect.txt"));
                    $this->mapHash(crawlHash($target), $new,
                        self::THUMBS_KIND);
                }
            }
        }
    }
    /**
     * mapHash records the version 10 folder of a pre-version 10 hash when
     * that hash has a folder on disk and has not been given one already.
     *
     * @param string $hash the pre-version 10 hash
     * @param string $new_folder its version 10 folder
     * @param string $kind PAGE_KIND, THUMBS_KIND or WHOLE_KIND
     */
    public function mapHash($hash, $new_folder, $kind)
    {
        if (isset($this->old_folders[$hash]) &&
            !isset($this->new_folders[$hash])) {
            $this->new_folders[$hash] = $new_folder;
            $this->hash_kinds[$hash] = $kind;
        }
    }
    /**
     * moveOldFolders moves each folder of the pre-version 10 layout to its
     * version 10 place: a page's or post's folder to <new>/page and its
     * thumbnail folder to <new>/thumbs, a user's folder or the feed
     * pictures to <new>. Each moves by one rename, which does not look
     * inside it. Folders are moved before links, so a link can be made
     * again pointing at its target's version 10 place, by a path relative
     * to the link. A folder whose version 10 place is taken, or whose hash
     * nothing names, is moved under UNMIGRATED_FOLDER.
     *
     * @return array pre-version 10 path => version 10 path of each folder
     *      moved, for rewriteStoredPaths
     */
    public function moveOldFolders()
    {
        $moved = [];
        $moved_by_name = [];
        $links = [];
        /* a page's own folders go before thumbnails reached through a
           redirect.txt, which several pages can share, so a page keeps
           its own thumbnails where both are found */
        $kind_order = [self::PAGE_KIND => 0, self::WHOLE_KIND => 1,
            self::THUMBS_KIND => 2];
        $hashes = array_keys($this->old_folders);
        usort($hashes, fn($first, $second) =>
            ($kind_order[$this->hash_kinds[$first] ?? ""] ?? 3) <=>
            ($kind_order[$this->hash_kinds[$second] ?? ""] ?? 3));
        foreach ($hashes as $hash) {
            $paths = $this->old_folders[$hash];
            foreach ($paths as $path) {
                if (is_link($path)) {
                    $links[$path] = $hash;
                    continue;
                }
                $new_path = $this->newPathOf($hash, $path);
                if ($new_path !== "" && !file_exists($new_path) &&
                    $this->renamePath($path, $new_path)) {
                    $moved[$path] = $new_path;
                    $moved_by_name[basename(dirname($path)) . "/" .
                        basename($path)] = $new_path;
                    $this->counts["folders moved"]++;
                } else {
                    $this->keepUnmigrated($path);
                }
            }
        }
        foreach ($links as $path => $hash) {
            /* a link names its target by the target's prefix folder and
               hash, which are the same however the work directory's path
               is written */
            $target = rtrim(strtr((string)readlink($path), "\\", "/"), "/");
            $new_path = $this->newPathOf($hash, $path);
            $new_target = $moved_by_name[basename(dirname($target)) . "/" .
                basename($target)] ?? "";
            if ($new_path !== "" && $new_target !== "" &&
                !file_exists($new_path) && !is_link($new_path)) {
                if (!file_exists(dirname($new_path))) {
                    makePath(dirname($new_path));
                }
                symlink(ResourceFolders::relativeLinkTarget($new_path,
                    $new_target), $new_path);
                $moved[$path] = $new_path;
                $this->counts["links remade"]++;
                $this->removeLink($path);
            } else {
                $this->keepUnmigrated($path);
            }
        }
        return $moved;
    }
    /**
     * removeEmptyPrefixFolders deletes each three-letter prefix folder of
     * the pre-version 10 layout that the moves left holding nothing but
     * files named in OS_FOLDER_FILES. A prefix folder holding anything
     * else is kept, and the report names it with the first REPORTED_NAMES
     * names in it, so the site's administrator can see why it stayed: a
     * folder whose name is no pre-version 10 hash, a hash that does not
     * begin with the prefix, a plain file, or a folder whose move failed.
     */
    public function removeEmptyPrefixFolders()
    {
        foreach (scandir($this->resource_path) ?: [] as $prefix) {
            $prefix_path = $this->resource_path . "/$prefix";
            if (strlen($prefix) != self::OLD_PREFIX_LEN ||
                !preg_match('/^[A-Za-z0-9_\-]+$/', $prefix) ||
                is_link($prefix_path) || !is_dir($prefix_path)) {
                continue;
            }
            $left = [];
            foreach (scandir($prefix_path) ?: [] as $name) {
                $path = "$prefix_path/$name";
                if ($name == "." || $name == ".." || (is_file($path) &&
                    !is_link($path) && in_array($name,
                    self::OS_FOLDER_FILES))) {
                    continue;
                }
                $left[] = $name;
            }
            if (empty($left)) {
                foreach (self::OS_FOLDER_FILES as $os_file) {
                    if (is_file("$prefix_path/$os_file")) {
                        unlink("$prefix_path/$os_file");
                    }
                }
                rmdir($prefix_path);
                continue;
            }
            $this->counts["prefix folders kept"]++;
            $this->log("kept $prefix_path, which holds " . implode(", ",
                array_slice($left, 0, self::REPORTED_NAMES)) .
                ((count($left) > self::REPORTED_NAMES) ? ", ..." : ""));
        }
    }
    /**
     * newPathOf gives where one folder of the pre-version 10 layout goes
     * in the version 10 layout.
     *
     * @param string $hash its hash
     * @param string $path its path, whose name begins with t for a
     *      thumbnail folder, or is the hash of "thumb" and the ids
     * @return string the version 10 path, or "" when nothing names the
     *      hash
     */
    public function newPathOf($hash, $path)
    {
        if (!isset($this->new_folders[$hash])) {
            return "";
        }
        $new_folder = $this->new_folders[$hash];
        $kind = $this->hash_kinds[$hash];
        $is_thumb_name = strlen(basename($path)) > self::OLD_HASH_LEN;
        if ($kind == self::WHOLE_KIND) {
            /* before version 10 the feed pictures were in the t folder of
               their hash, and a user's icon in the folder of its hash */
            $is_feeds = basename($new_folder) == ResourceFolders::FEEDS_FOLDER;
            return ($is_feeds == $is_thumb_name) ? $new_folder : "";
        }
        $sub_folder = ($kind == self::PAGE_KIND && !$is_thumb_name) ?
            ResourceFolders::PAGE_FOLDER : ResourceFolders::THUMBS_FOLDER;
        return "$new_folder/$sub_folder";
    }
    /**
     * renamePath moves a folder, making the folders its new place sits in
     * first.
     *
     * @param string $path the folder to move
     * @param string $new_path where it goes
     * @return bool whether it was moved
     */
    public function renamePath($path, $new_path)
    {
        if (!file_exists(dirname($new_path)) &&
            !makePath(dirname($new_path))) {
            return false;
        }
        return @rename($path, $new_path);
    }
    /**
     * keepUnmigrated moves a folder or link of the pre-version 10 layout
     * that has no version 10 place to
     * app/resources/UNMIGRATED_FOLDER/<prefix>/<name>, so the pre-version
     * 10 layout is gone from app/resources but nothing is deleted.
     *
     * @param string $path the folder or link
     */
    public function keepUnmigrated($path)
    {
        $prefix = basename(dirname($path));
        $new_path = $this->resource_path . "/" . self::UNMIGRATED_FOLDER .
            "/$prefix/" . basename($path);
        if (!file_exists($new_path) && !is_link($new_path) &&
            $this->renamePath($path, $new_path)) {
            $this->counts["folders kept in unmigrated"]++;
        }
    }
    /**
     * removeLink removes a link to a folder, which Windows removes with
     * rmdir and Linux and macOS with unlink.
     *
     * @param string $path the link
     */
    public function removeLink($path)
    {
        if (!@unlink($path)) {
            @rmdir($path);
        }
    }
    /**
     * rewriteStoredPaths changes the folder paths kept outside the folders
     * themselves to their version 10 places: those in recommendation.txt
     * and needs_descriptions.txt and the RESOURCE_PATH column of
     * GROUP_RESOURCE_RECOMMENDATION. The lists in needs_thumbs name
     * pre-version 10 folders and are deleted; a missing thumbnail is made
     * when it is asked for.
     *
     * @param array $moved pre-version 10 path => version 10 path, as
     *      moveOldFolders gives
     */
    public function rewriteStoredPaths($moved)
    {
        if (empty($moved)) {
            return;
        }
        $replacements = [];
        foreach ($moved as $old => $new) {
            $replacements[$old . "/"] = $new . "/";
        }
        foreach (self::KEPT_TOP_FILES as $file_name) {
            $file_path = $this->resource_path . "/$file_name";
            if (is_file($file_path)) {
                $text = file_get_contents($file_path) . "\n";
                $lines = explode("\n", $text);
                foreach ($lines as $index => $line) {
                    $lines[$index] = rtrim(strtr($line . "/",
                        $replacements), "/");
                }
                file_put_contents($file_path, rtrim(implode("\n", $lines),
                    "\n") . "\n");
                $this->counts["paths rewritten"]++;
            }
        }
        foreach (glob($this->resource_path . "/needs_thumbs/*.txt") ?: []
            as $list) {
            unlink($list);
        }
        $db = $this->db;
        $rows = [];
        $result = $db->execute("SELECT DISTINCT RESOURCE_PATH FROM " .
            "GROUP_RESOURCE_RECOMMENDATION");
        while ($result && ($row = $db->fetchArray($result))) {
            $rows[] = $row['RESOURCE_PATH'];
        }
        foreach ($rows as $old_path) {
            $new_path = strtr($old_path, $replacements);
            if ($new_path !== $old_path) {
                $db->execute("UPDATE GROUP_RESOURCE_RECOMMENDATION SET " .
                    "RESOURCE_PATH = ? WHERE RESOURCE_PATH = ?",
                    [$new_path, $old_path]);
                $this->counts["paths rewritten"]++;
            }
        }
    }
    /**
     * moveSiteFiles moves the site's appearance files, which before
     * version 10 sat directly in app/resources, that is, every file there
     * other than KEPT_TOP_FILES, into ResourceFolders::sitePath. It moves
     * the pre-version 10 folder of each host with a row in
     * DOMAIN_APPEARANCE, app/resources/<host>, into
     * ResourceFolders::domainPath. A file or folder whose version 10 place
     * is taken is left where it is.
     */
    public function moveSiteFiles()
    {
        $site_path = ResourceFolders::sitePath($this->resource_path);
        foreach (scandir($this->resource_path) ?: [] as $name) {
            $path = $this->resource_path . "/$name";
            if (!is_link($path) && is_file($path) &&
                !in_array($name, self::KEPT_TOP_FILES) &&
                !file_exists("$site_path/$name") &&
                $this->renamePath($path, "$site_path/$name")) {
                $this->counts["site files moved"]++;
            }
        }
        $db = $this->db;
        $hosts = [];
        $result = $db->execute("SELECT DOMAIN FROM DOMAIN_APPEARANCE");
        while ($result && ($row = $db->fetchArray($result))) {
            $hosts[] = $row['DOMAIN'];
        }
        foreach ($hosts as $host) {
            $old_path = $this->resource_path . "/" .
                preg_replace("/[^A-Za-z0-9_\-\.]/", "", (string)$host);
            $new_path = ResourceFolders::domainPath($host,
                $this->resource_path);
            if ($new_path !== "" && $old_path !== $this->resource_path . "/"
                && !in_array(basename($old_path), self::KEPT_TOP_FOLDERS) &&
                !is_link($old_path) && is_dir($old_path) &&
                !file_exists($new_path) &&
                $this->renamePath($old_path, $new_path)) {
                $this->counts["host folders moved"]++;
            }
        }
    }
    /**
     * log passes one line of the report to log_function, if there is one.
     *
     * @param string $line the line
     */
    public function log($line)
    {
        if (is_callable($this->log_function)) {
            ($this->log_function)($line);
        }
    }
}