/ src / library / DiscussionImport.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;

/**
 * Pure helper logic for importing an outside discussion board into a
 * Yioop group or issue tracker. These are the parts that do no database or
 * model work, kept apart from the model-driven import in GroupWikiTool so
 * they can be tested on their own. For now they are the helpers for the
 * MantisBT bug tracker: reading a mysqldump's INSERT rows, deciding whether
 * a MantisBT username can be reused, building an issue's body and a
 * fallback reporter's byline, turning a stored attachment back into its
 * bytes, and writing the wiki references for a post's attachments. Helpers
 * for other boards can join them here as they are added.
 *
 * @author Chris Pollett
 */
class DiscussionImport
{
    /**
     * Parses the rows of a mysqldump INSERT for one table into a list of
     * value arrays. Works on the output of
     * mysqldump --no-create-info, where each table's data arrives as one or
     * more INSERT INTO `table` VALUES (..),(..); statements. Reads the values
     * a character at a time so quoted strings that contain commas, parentheses,
     * doubled '' quotes, or backslash escapes are kept whole. An unquoted NULL
     * becomes a PHP null; every other value is returned as a string, positioned
     * the same as its column in the table.
     *
     * @param string $sql the whole dump text
     * @param string $table name of the table whose rows are wanted
     * @return array a list of rows, each row a list of column values
     */
    public static function parseInserts($sql, $table)
    {
        $rows = [];
        $marker = "INSERT INTO `$table` VALUES ";
        $length = strlen($sql);
        $offset = 0;
        while (($start = strpos($sql, $marker, $offset)) !== false) {
            $pos = $start + strlen($marker);
            $current = [];
            $value = "";
            $quoted = false;
            $in_string = false;
            while ($pos < $length) {
                $char = $sql[$pos];
                if ($in_string) {
                    if ($char === "\\" && $pos + 1 < $length) {
                        $escapes = ["n" => "\n", "r" => "\r", "t" => "\t",
                            "0" => "\0", "b" => "\x08", "Z" => "\x1a"];
                        $value .= $escapes[$sql[$pos + 1]] ?? $sql[$pos + 1];
                        $pos += 2;
                        continue;
                    }
                    if ($char === "'" && ($sql[$pos + 1] ?? "") === "'") {
                        $value .= "'";
                        $pos += 2;
                        continue;
                    }
                    if ($char === "'") {
                        $in_string = false;
                        $pos++;
                        continue;
                    }
                    $value .= $char;
                    $pos++;
                    continue;
                }
                if ($char === "'") {
                    $in_string = true;
                    $quoted = true;
                    $pos++;
                    continue;
                }
                if ($char === "(") {
                    $current = [];
                    $value = "";
                    $quoted = false;
                    $pos++;
                    continue;
                }
                if ($char === "," || $char === ")") {
                    if ($quoted) {
                        $current[] = $value;
                    } else {
                        $trimmed = trim($value);
                        $current[] = ($trimmed === "NULL") ? null : $trimmed;
                    }
                    $value = "";
                    $quoted = false;
                    if ($char === ")") {
                        $rows[] = $current;
                        $pos++;
                        while ($pos < $length &&
                            strpos(" \n\r\t", $sql[$pos]) !== false) {
                            $pos++;
                        }
                        if (($sql[$pos] ?? "") === ";") {
                            break;
                        }
                        if (($sql[$pos] ?? "") === ",") {
                            $pos++;
                        }
                    } else {
                        $pos++;
                    }
                    continue;
                }
                $value .= $char;
                $pos++;
            }
            $offset = $pos;
        }
        return $rows;
    }
    /**
     * Decides whether a MantisBT username can be reused as a Yioop username.
     * A compatible name is non-empty, no longer than Yioop allows, and made
     * only of letters, digits, and the few punctuation marks Yioop keeps in a
     * name, so it can be stored and shown without cleaning changing it.
     *
     * @param string $username the MantisBT username to weigh
     * @return bool whether Yioop can take the name as is
     */
    public static function usernameCompatible($username)
    {
        $trimmed = trim($username);
        if ($trimmed === "" || strlen($trimmed) > C\NAME_LEN) {
            return false;
        }
        return preg_match('/^[a-zA-Z0-9_.\-]+$/', $trimmed) === 1;
    }
    /**
     * Makes a readable byline for a MantisBT user who could not be given their
     * own Yioop account, so their name and email are not lost when the shared
     * bot-sender account owns their issue or comment. Prefers the real name,
     * falls back to the username, and adds the email when there is one.
     *
     * @param array $mantis the MantisBT user's username, real name, and email
     * @return string the byline, empty when nothing is known
     */
    public static function fallbackByline($mantis)
    {
        $name = trim($mantis["realname"] ?? "");
        if ($name === "") {
            $name = trim($mantis["username"] ?? "");
        }
        $email = trim($mantis["email"] ?? "");
        if ($name === "" && $email === "") {
            return "";
        }
        if ($email === "") {
            return $name;
        }
        return ($name !== "") ? "$name <$email>" : $email;
    }
    /**
     * Builds the body text of an imported issue from the bug's original
     * description, steps to reproduce, and additional information. When the
     * reporter could not be given their own Yioop account, a short footer
     * records who originally reported it so their name is not lost; nothing
     * else is added, since the issue's own fields already carry its number,
     * priority, and the rest.
     *
     * @param array $text the description, steps, and additional-information
     *      strings for the bug
     * @param string $reporter_note byline for the original reporter, added
     *      only when they were folded onto the shared bot account; empty
     *      otherwise
     * @return string the issue body
     */
    public static function issueBody($text, $reporter_note)
    {
        $body = trim($text[0] ?? "");
        if (trim($text[1] ?? "") !== "") {
            $body .= "\n\nSteps to reproduce:\n" . trim($text[1]);
        }
        if (trim($text[2] ?? "") !== "") {
            $body .= "\n\nAdditional information:\n" . trim($text[2]);
        }
        if ($reporter_note !== "") {
            $body .= "\n\n----\nOriginally reported by " . $reporter_note . ".";
        }
        return $body;
    }
    /**
     * Turns the stored bytes of a MantisBT attachment, as they appear in a
     * mysqldump, back into the raw file. A dump made with the hex-blob option
     * writes the bytes as a 0x-prefixed hexadecimal literal, which is decoded
     * here; otherwise the value is already the raw bytes, unescaped by the
     * INSERT parser, and is returned unchanged. An empty value, which happens
     * for an attachment MantisBT kept on disk rather than in the database,
     * yields an empty string.
     *
     * @param string $raw the content column's value from the dump
     * @return string the attachment's bytes, empty when the dump holds none
     */
    public static function fileContent($raw)
    {
        if ($raw === null || $raw === "") {
            return "";
        }
        if (preg_match('/^0x[0-9a-fA-F]+$/', $raw)) {
            return hex2bin(substr($raw, 2));
        }
        return $raw;
    }
    /**
     * Builds the wiki resource references for a list of imported MantisBT
     * files, one per file that has bytes, to add to a post's body so each file
     * shows as a link or image when the post is read. This is done before the
     * post is made, since the reference names the file, while the bytes are
     * stored afterwards once the post has an id.
     *
     * @param array $files the file rows, each with a filename and bytes
     * @return string the resource references, each on its own line
     */
    public static function fileReferences($files)
    {
        $references = "";
        foreach ($files as $file) {
            if ($file["content"] === "") {
                continue;
            }
            $references .= "\n\n((resource:" . $file["filename"] . "|" .
                $file["filename"] . "))";
        }
        return $references;
    }}
X