<?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\wiki;
/**
* WikiPagesFile reads and writes a file of wiki pages in the form
* PublicHelpPages.php has. Such a file holds named arrays of pages, such
* as $public_pages and $help_pages, each keyed by locale tag and then by
* page name. Each page's text sits between a line ending in <<< 'EOD' and
* a line holding only EOD;. That form is PHP, but the file is only read
* as text here and never run, so a file made on one site and given to
* another runs no code. ConfigureTool uses it to make the databases and to
* export and import the public and help pages, and GroupWikiTool uses it
* to export and import the pages of a group.
*/
class WikiPagesFile
{
/**
* END_CHARACTERS matches what may come after a page's end mark on a
* line PHP 7.3 and later would take as the end of the page's text: no
* letter, digit or underscore. A page holding a line that starts, after
* spaces or tabs, with its end mark followed by none of these would be
* ended there, so write picks a mark no such line holds.
*/
const END_CHARACTERS = '(?![A-Za-z0-9_\x80-\xff])';
/**
* PUBDATES is put after the name of an array of pages to name the
* array that holds the time of each page's revision, such as
* $public_pages_pubdates beside $public_pages. The time is a Unix time,
* in seconds.
*/
const PUBDATES = "_pubdates";
/**
* COMMENTS is put after the name of an array of pages to name the
* array that holds the posts of each page's discussion thread, such as
* $public_pages_comments beside $public_pages. Each entry is one line
* of JSON (JavaScript Object Notation), a list of the thread's posts.
*/
const COMMENTS = "_comments";
/**
* HISTORY is put after the name of an array of pages to name the array
* that holds every revision of each page, such as
* $public_pages_history beside $public_pages. Each entry is one line of
* JSON, a list of the page's revisions, oldest first.
*/
const HISTORY = "_history";
/**
* PARTS is the name of the variable, near the top of a file, that says
* which kinds of data the file carries besides the pages, as words
* separated by spaces: issue_pages, comments and history. An import
* that deletes first deletes only the kinds the file carries.
*/
const PARTS = "exported_parts";
/**
* read gives the named arrays of pages that a file in the form of
* PublicHelpPages.php holds, without running the file. It finds each
* line of the form $name["locale"]["page"] = <<< 'EOD' and takes the
* lines after it, up to the line EOD;, as that page's text. A page
* written with another end mark, such as EOD_1, ends at that mark.
*
* The file is read a line at a time by eachPage, so the memory read
* needs is about the size of the pages themselves.
*
* @param string $file_name the file to read
* @return array the file's arrays: array name, then locale tag, then
* page name, to the page's text; empty if the file cannot be read
*/
public static function read($file_name)
{
$arrays = [];
foreach (self::eachPage($file_name) as [$array_name, $locale_tag,
$page_name, $page]) {
$arrays[$array_name][$locale_tag][$page_name] = $page;
}
return $arrays;
}
/**
* exportedParts gives the kinds of data a file in the form of
* PublicHelpPages.php says it carries besides the pages, from the
* variable PARTS names near its top. An import that deletes first
* calls it, so it deletes only the kinds the file brings back. A file
* written before such files said so, which carried git issue pages
* and neither comments nor history, gives just issue_pages.
*
* @param string $file_name the file to read
* @return array the kinds the file carries, among issue_pages,
* comments and history
*/
public static function exportedParts($file_name)
{
$handle = @fopen($file_name, "rb");
if ($handle === false) {
return [];
}
$parts = ["issue_pages"];
$pattern = '/^\$' . self::PARTS . ' = "([a-z_ ]*)";$/';
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
if (preg_match($pattern, $line, $match)) {
$parts = array_values(array_filter(explode(" ", $match[1])));
break;
}
if (str_contains($line, "<<< 'EOD")) {
break;
}
}
fclose($handle);
return $parts;
}
/**
* exportedGroups gives the arrays of pages a file in the form of
* PublicHelpPages.php declares, among public_pages and help_pages.
* write declares each array it is given, even one with no pages, so an
* export of just the Public group or just the Help group declares just
* that one. An import that deletes first deletes pages only in the
* groups the file declares, and createdb takes a group the file does
* not declare from the file that ships with Yioop.
*
* @param string $file_name the file to read
* @return array the names of the arrays the file declares, in the
* order it declares them; empty if the file cannot be read
*/
public static function exportedGroups($file_name)
{
$handle = @fopen($file_name, "rb");
if ($handle === false) {
return [];
}
$groups = [];
$pattern = '/^(?:\s*\*\/)?\$(public_pages|help_pages) = \[\];$/';
while (($line = fgets($handle)) !== false) {
if (preg_match($pattern, rtrim($line, "\r\n"), $match) &&
!in_array($match[1], $groups)) {
$groups[] = $match[1];
}
}
fclose($handle);
return $groups;
}
/**
* eachPage hands out the pages of a file in the form of
* PublicHelpPages.php one at a time, in the order the file lists them,
* reading the file a line at a time. upgradePublicHelpWiki writes each
* page to the database as it comes, so importing a site's export of
* many pages holds one page in memory rather than all of them. A page
* whose time is in the file, on the line just before the page, is
* handed out with that time, so the import dates it as the site that
* wrote the file had it dated. A page whose thread's posts are in the
* file, just before the page, is handed out with those posts.
*
* @param string $file_name the file to read
* @return \Generator yields [array name, locale tag, page name, the
* page's text, the Unix time of the page's revision or -1 where the
* file gives none, the list of the page's thread's posts or null
* where the file gives none, the list of the page's revisions or
* null where the file gives none] for each page; nothing if the
* file cannot be read
*/
public static function eachPage($file_name)
{
$handle = @fopen($file_name, "rb");
if ($handle === false) {
return;
}
$start_pattern = '/^\$(\w+)\["([^"]*)"\]\["([^"]*)"\] = ' .
"<<< '(EOD[A-Z0-9_]*)'$/u";
$pubdate_pattern = '/^\$(\w+)' . self::PUBDATES . '\["([^"]*)"\]' .
'\["([^"]*)"\] = (\d+);$/u';
$pubdate_key = "";
$pubdate = -1;
$side_keys = [self::COMMENTS => "", self::HISTORY => ""];
$side_values = [self::COMMENTS => null, self::HISTORY => null];
$page = null;
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
if ($page === null) {
if (preg_match($pubdate_pattern, $line, $match)) {
$pubdate_key = "$match[1]\n$match[2]\n$match[3]";
$pubdate = intval($match[4]);
} elseif (preg_match($start_pattern, $line, $match)) {
[, $array_name, $locale_tag, $page_name, $mark] =
$match;
$end_mark = "$mark;";
$page = "";
$first_line = true;
}
continue;
}
if ($line === $end_mark) {
$side = "";
foreach ([self::COMMENTS, self::HISTORY] as $suffix) {
$suffix_at = strlen($array_name) - strlen($suffix);
if ($suffix_at > 0 &&
substr($array_name, $suffix_at) == $suffix) {
$side = $suffix;
$side_keys[$suffix] = substr($array_name, 0,
$suffix_at) . "\n$locale_tag\n$page_name";
$value = json_decode($page, true);
$side_values[$suffix] = is_array($value) ? $value :
null;
}
}
if ($side == "") {
$key = "$array_name\n$locale_tag\n$page_name";
$found = [];
foreach ($side_keys as $suffix => $side_key) {
$found[$suffix] = ($side_key == $key) ?
$side_values[$suffix] : null;
$side_keys[$suffix] = "";
}
yield [$array_name, $locale_tag, $page_name, $page,
($pubdate_key == $key) ? $pubdate : -1,
$found[self::COMMENTS], $found[self::HISTORY]];
$pubdate_key = "";
}
$page = null;
continue;
}
$page .= ($first_line ? "" : "\n") . $line;
$first_line = false;
}
fclose($handle);
}
/**
* write saves named arrays of pages to a file in the form of
* PublicHelpPages.php. The file starts with Yioop's license, a title
* and a line naming the command that made it, and each array has a
* docblock with its description. Each page's text ends at the line
* EOD;. A page holding a line PHP would take as that end, such as a
* page showing this file's own form, is written with EOD_1, EOD_2 or
* the first such mark none of its lines starts with, so every page is
* written and the file stays valid PHP. Where the time of a page's
* revision is given, it is written on the line before the page, in an
* array named for the page's array with PUBDATES after it. Where the
* posts of a page's thread are given, they are written before the page
* as one line of JSON, in an array named with COMMENTS after it. A
* reader of the file from before these were written takes them as
* arrays of their own, which the import does not use. A page's
* revisions are written the same way, in an array named with HISTORY
* after it; they are asked for one page at a time, so a large export
* is never held whole, and the file is written as it is made. Where
* the kinds of data the file carries are given, they are written near
* the top in the variable PARTS names.
*
* @param string $file_name the file to write
* @param array $arrays array name to [description, pages, times,
* comments, history], where the last three may be left off. Pages
* are keyed by locale tag and then by page name. Times, keyed the
* same way, give the Unix time of each page's revision, and
* comments, keyed the same way, give the list of posts of each
* page's thread. History is a function taking a locale tag and a
* page name and giving the list of that page's revisions.
* @param string $title what the file holds, for its header
* @param string $made_by the command that made the file, for its
* header
* @param array $parts the kinds of data the file carries besides the
* pages, among issue_pages, comments and history; null writes no
* PARTS line, as for a file whose kinds are not known
*/
public static function write($file_name, $arrays, $title, $made_by,
$parts = null)
{
$handle = fopen($file_name, "wb");
if ($handle === false) {
return;
}
$out = "<" . "?php\n" . <<< EOD
/**
* 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
*
* $title
*
* This file should be generated using $made_by
*
* @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\configs;
EOD;
if ($parts !== null) {
$out .= "/**\n * The kinds of data this file carries besides " .
"the pages\n * @var string\n */\n\$" . self::PARTS .
" = \"" . implode(" ", $parts) . "\";\n";
}
fwrite($handle, $out);
$json_flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES |
JSON_INVALID_UTF8_SUBSTITUTE;
foreach ($arrays as $array_name => $array) {
[$description, $pages] = $array;
$pubdates = $array[2] ?? [];
$comments = $array[3] ?? [];
$history = $array[4] ?? null;
fwrite($handle, "/**\n * $description\n * @var array\n */\n" .
"\$$array_name = [];\n");
foreach (self::pageEntries($pages) as [$locale_tag, $page_name,
$page]) {
$page = str_replace("\r", "", $page);
/* A page holding a line PHP would read as its end,
such as a page showing this file's own form, gets
an end mark with a number added that no line of
it starts with. */
$mark = "EOD";
for ($number = 1; preg_match('/^[ \t]*' . $mark .
self::END_CHARACTERS . '/m', $page); $number++) {
$mark = "EOD_$number";
}
$out = "";
if (isset($pubdates[$locale_tag][$page_name])) {
$out .= "\$$array_name" . self::PUBDATES .
"[\"$locale_tag\"][\"$page_name\"] = " .
intval($pubdates[$locale_tag][$page_name]) .
";\n";
}
if (!empty($comments[$locale_tag][$page_name])) {
/* JSON written on one line starts with a bracket,
so it never holds a line read as its end. */
$out .= "\$$array_name" . self::COMMENTS .
"[\"$locale_tag\"][\"$page_name\"] = " .
"<<< 'EOD'\n" . json_encode(
$comments[$locale_tag][$page_name],
$json_flags) . "\nEOD;\n";
}
$revisions = ($history === null) ? [] :
$history($locale_tag, $page_name);
if (!empty($revisions)) {
$out .= "\$$array_name" . self::HISTORY .
"[\"$locale_tag\"][\"$page_name\"] = " .
"<<< 'EOD'\n" . json_encode($revisions,
$json_flags) . "\nEOD;\n";
}
fwrite($handle, $out . "\$$array_name" .
"[\"$locale_tag\"][\"$page_name\"] = " .
"<<< '$mark'\n$page\n$mark;\n");
}
}
fclose($handle);
}
/**
* pageEntries hands out the pages of one array given to write as
* [locale tag, page name, text] triples, in order. The pages may be a
* nested array, keyed by locale tag and then by page name, or a
* generator already handing out such triples, as for a group's
* discussions, which are read one thread at a time so a large group's
* discussions are never held whole.
*
* @param iterable $pages a nested array of pages, or a generator of
* [locale tag, page name, text] triples
* @return \Generator yields [locale tag, page name, text] for each page
*/
private static function pageEntries($pages)
{
if ($pages instanceof \Generator) {
yield from $pages;
return;
}
foreach ($pages as $locale_tag => $locale_pages) {
foreach ($locale_pages as $page_name => $page) {
yield [$locale_tag, $page_name, $page];
}
}
}
}