Store permalink data, implement Special:FindComment/GoToComment

Depends-On: I90656cc74bb1cb1f2f3c82ad51cfb164cb8a4a4b
Bug: T296801
Change-Id: I84187b303aa10a242c872088403f808df3d1f940
This commit is contained in:
Bartosz Dziewoński 2022-02-17 00:29:10 +01:00
parent 91a94fe9bb
commit 0024a94ba7
52 changed files with 2701 additions and 4 deletions

View file

@ -11,6 +11,8 @@ $specialPageAliases = [];
/** English (English) */
$specialPageAliases['en'] = [
'TopicSubscriptions' => [ 'TopicSubscriptions' ],
'FindComment' => [ 'FindComment' ],
'GoToComment' => [ 'GoToComment' ],
];
/** čeština (Czech) */

View file

@ -18,8 +18,11 @@
],
"dbschema": [
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_subscription.json --sql sql/mysql/discussiontools_subscription.sql --type mysql",
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_persistent.json --sql sql/mysql/discussiontools_persistent.sql --type mysql",
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_subscription.json --sql sql/postgres/discussiontools_subscription.sql --type postgres",
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_subscription.json --sql sql/sqlite/discussiontools_subscription.sql --type sqlite"
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_persistent.json --sql sql/postgres/discussiontools_persistent.sql --type postgres",
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_subscription.json --sql sql/sqlite/discussiontools_subscription.sql --type sqlite",
"php ../../maintenance/generateSchemaSql.php --json sql/discussiontools_persistent.json --sql sql/sqlite/discussiontools_persistent.sql --type sqlite"
],
"phan": "phan -d . --long-progress-bar",
"phpcs": "phpcs -sp --cache"

View file

@ -470,6 +470,19 @@
"LinkRenderer",
"LinkBatchFactory"
]
},
"FindComment": {
"class": "\\MediaWiki\\Extension\\DiscussionTools\\SpecialFindComment",
"services": [
"DiscussionTools.ThreadItemStore",
"DiscussionTools.ThreadItemFormatter"
]
},
"GoToComment": {
"class": "\\MediaWiki\\Extension\\DiscussionTools\\SpecialGoToComment",
"services": [
"DiscussionTools.ThreadItemStore"
]
}
},
"Hooks": {
@ -477,6 +490,7 @@
"EchoGetBundleRules": "\\MediaWiki\\Extension\\DiscussionTools\\Hooks\\EchoHooks::onEchoGetBundleRules",
"EchoGetEventsForRevision": "\\MediaWiki\\Extension\\DiscussionTools\\Hooks\\EchoHooks::onEchoGetEventsForRevision",
"MinervaNeueTalkPageOverlay": "\\MediaWiki\\Extension\\DiscussionTools\\Hooks\\MobileHooks::onMinervaNeueTalkPageOverlay",
"RevisionDataUpdates": "dataupdates",
"LoadExtensionSchemaUpdates": "installer",
"ParserAfterTidy": "parser",
"ArticleViewHeader": "page",
@ -494,6 +508,12 @@
"RecentChange_save": "tags"
},
"HookHandlers": {
"dataupdates": {
"class": "MediaWiki\\Extension\\DiscussionTools\\Hooks\\DataUpdatesHooks",
"services": [
"DiscussionTools.ThreadItemStore"
]
},
"installer": {
"class": "MediaWiki\\Extension\\DiscussionTools\\Hooks\\InstallerHooks"
},

View file

@ -35,6 +35,15 @@
"discussiontools-error-noswitchtove-table": "table syntax",
"discussiontools-error-noswitchtove-template": "template syntax",
"discussiontools-error-noswitchtove-title": "Visual mode disabled",
"discussiontools-findcomment-gotocomment": "If it only appears in the current revision of one page, you can [[Special:GoToComment/$1|go directly to the comment using this link]]. Otherwise it will redirect to these search results.",
"discussiontools-findcomment-label-idorname": "Comment ID or name",
"discussiontools-findcomment-label-search": "Search",
"discussiontools-findcomment-noresults": "No results.",
"discussiontools-findcomment-results-id": "Comment with the given ID has appeared on the following {{PLURAL:$1|page|pages}}:",
"discussiontools-findcomment-results-name": "Comment with the given name has appeared on the following {{PLURAL:$1|page|pages}}:",
"discussiontools-findcomment-results-notcurrent": "(not in current revision)",
"discussiontools-findcomment-results-transcluded": "(transcluded from another page)",
"discussiontools-findcomment-title": "Find comment",
"discussiontools-limitreport-errorreqid": "DiscussionTools error request ID",
"discussiontools-limitreport-timeusage": "DiscussionTools time usage",
"discussiontools-limitreport-timeusage-value": "$1 {{PLURAL:$1|second|seconds}}",

View file

@ -47,6 +47,15 @@
"discussiontools-error-noswitchtove-table": "Type of syntax detected, used as a parameter in {{msg-mw|discussiontools-error-noswitchtove}}.",
"discussiontools-error-noswitchtove-template": "Type of syntax detected, used as a parameter in {{msg-mw|discussiontools-error-noswitchtove}}.",
"discussiontools-error-noswitchtove-title": "Type of syntax detected, used as a parameter in {{msg-mw|discussiontools-error-noswitchtove}}.",
"discussiontools-findcomment-gotocomment": "Message shown after submitting the form on [[Special:FindComment]]. Follows {{msg-mw|discussiontools-findcomment-results-id}} or {{msg-mw|discussiontools-findcomment-results-name}} and a list of results.\n\nParameters:\n* $1 comment ID, can only be used in link target",
"discussiontools-findcomment-label-idorname": "Text field label on [[Special:FindComment]]",
"discussiontools-findcomment-label-search": "Button label on [[Special:FindComment]]\n\n'ID' and 'name' are technical terms, see https://www.mediawiki.org/wiki/Extension:DiscussionTools/How_it_works#Assigning_ID_and_name",
"discussiontools-findcomment-noresults": "Message shown after submitting the form on [[Special:FindComment]].",
"discussiontools-findcomment-results-id": "Message shown after submitting the form on [[Special:FindComment]]. Followed by a bulleted list.\n\nParameters:\n* $1 number of results\n\n'ID' and 'name' are technical terms, see https://www.mediawiki.org/wiki/Extension:DiscussionTools/How_it_works#Assigning_ID_and_name",
"discussiontools-findcomment-results-name": "Message shown after submitting the form on [[Special:FindComment]]. Followed by a bulleted list.\n\nParameters:\n* $1 number of results\n\n'ID' and 'name' are technical terms, see https://www.mediawiki.org/wiki/Extension:DiscussionTools/How_it_works#Assigning_ID_and_name",
"discussiontools-findcomment-results-notcurrent": "Additional label for a result on [[Special:FindComment]], shown following a link to a page.",
"discussiontools-findcomment-results-transcluded": "Additional label for a result on [[Special:FindComment]], shown following a link to a page.",
"discussiontools-findcomment-title": "Page title for [[Special:FindComment]], also shown on the list on [[Special:SpecialPages]]",
"discussiontools-limitreport-errorreqid": "Label for the ID of the web request in which a DiscussionTools error has occurred.",
"discussiontools-limitreport-timeusage": "Label for the time usage (duration) of DiscussionTools in the parser limit report. Followed by {{msg-mw|discussiontools-limitreport-timeusage-value}}.\n\nSimilar to:\n* {{msg-mw|limitreport-cputime}}\n* {{msg-mw|limitreport-walltime}}\n* {{msg-mw|scribunto-limitreport-timeusage}}",
"discussiontools-limitreport-timeusage-value": "Follows {{msg-mw|discussiontools-limitreport-timeusage}}.\n\nParameters:\n* $1 - the usage in seconds\n{{Identical|Second}}",

View file

@ -0,0 +1,52 @@
<?php
/**
* DiscussionTools data updates hooks
*
* @file
* @ingroup Extensions
* @license MIT
*/
namespace MediaWiki\Extension\DiscussionTools\Hooks;
use DeferrableUpdate;
use MediaWiki\Extension\DiscussionTools\ThreadItemStore;
use MediaWiki\Revision\RenderedRevision;
use MediaWiki\Storage\Hook\RevisionDataUpdatesHook;
use MWCallableUpdate;
use Title;
class DataUpdatesHooks implements RevisionDataUpdatesHook {
/** @var ThreadItemStore */
private $threadItemStore;
/**
* @param ThreadItemStore $threadItemStore
*/
public function __construct(
ThreadItemStore $threadItemStore
) {
$this->threadItemStore = $threadItemStore;
}
/**
* @param Title $title
* @param RenderedRevision $renderedRevision
* @param DeferrableUpdate[] &$updates
* @return bool|void
*/
public function onRevisionDataUpdates( $title, $renderedRevision, &$updates ) {
// This doesn't trigger on action=purge, only on automatic purge after editing a template or
// transcluded page, and API action=purge&forcelinkupdate=1.
// TODO Deduplicate work between this and the Echo hook (make it use Parsoid too)
$rev = $renderedRevision->getRevision();
if ( HookUtils::isAvailableForTitle( $title ) ) {
$updates[] = new MWCallableUpdate( function () use ( $rev ) {
$threadItemSet = HookUtils::parseRevisionParsoidHtml( $rev );
$this->threadItemStore->insertThreadItems( $rev, $threadItemSet );
}, __METHOD__ );
}
}
}

View file

@ -11,13 +11,22 @@ namespace MediaWiki\Extension\DiscussionTools\Hooks;
use ExtensionRegistry;
use IContextSource;
use MediaWiki\Extension\DiscussionTools\CommentUtils;
use MediaWiki\Extension\DiscussionTools\ContentThreadItemSet;
use MediaWiki\Extension\VisualEditor\ParsoidHelper;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\User\UserIdentity;
use MWException;
use OutputPage;
use Psr\Log\NullLogger;
use RequestContext;
use Title;
use TitleValue;
use Wikimedia\Assert\Assert;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMUtils;
class HookUtils {
public const REPLYTOOL = 'replytool';
@ -67,6 +76,43 @@ class HookUtils {
return static::$propCache[ $id ][ $prop ];
}
/**
* Parse a revision by using the discussion parser on the HTML provided by Parsoid.
*
* @param RevisionRecord $revRecord
* @return ContentThreadItemSet
*/
public static function parseRevisionParsoidHtml( RevisionRecord $revRecord ): ContentThreadItemSet {
$services = MediaWikiServices::getInstance();
$parsoidHelper = new ParsoidHelper(
$services->getMainConfig(),
new NullLogger(),
false
);
// Get HTML for the revision
$status = $parsoidHelper->requestRestbasePageHtml( $revRecord );
if ( !$status->isOK() ) {
[ 'message' => $msg, 'params' => $params ] = $status->getErrors()[0];
throw new MWException( wfMessage( $msg, ...$params )->text() );
}
$title = TitleValue::newFromPage( $revRecord->getPage() );
$response = $status->getValue();
$html = $response['body'];
// Run the discussion parser on it
$doc = DOMUtils::parseHTML( $html );
$container = DOMCompat::getBody( $doc );
CommentUtils::unwrapParsoidSections( $container );
$parser = $services->getService( 'DiscussionTools.CommentParser' );
return $parser->parse( $container, $title );
}
/**
* Check if a DiscussionTools feature is available to this user
*

View file

@ -30,5 +30,9 @@ class InstallerHooks implements
'discussiontools_subscription',
"$base/../sql/$type/discussiontools_subscription.sql"
);
$updater->addExtensionTable(
'discussiontools_items',
"$base/../sql/$type/discussiontools_persistent.sql"
);
}
}

View file

@ -29,5 +29,22 @@ return [
$services->getReadOnlyMode(),
$services->getUserFactory()
);
}
},
'DiscussionTools.ThreadItemStore' => static function ( MediaWikiServices $services ): ThreadItemStore {
return new ThreadItemStore(
$services->getConfigFactory(),
$services->getDBLoadBalancerFactory(),
$services->getReadOnlyMode(),
$services->getPageStore(),
$services->getRevisionStore(),
$services->getTitleFormatter(),
$services->getActorStore()
);
},
'DiscussionTools.ThreadItemFormatter' => static function ( MediaWikiServices $services ): ThreadItemFormatter {
return new ThreadItemFormatter(
$services->getTitleFormatter(),
$services->getLinkRenderer()
);
},
];

View file

@ -0,0 +1,128 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
use FormSpecialPage;
use Html;
use HTMLForm;
class SpecialFindComment extends FormSpecialPage {
/** @var ThreadItemStore */
private $threadItemStore;
/** @var ThreadItemFormatter */
private $threadItemFormatter;
/**
* @param ThreadItemStore $threadItemStore
* @param ThreadItemFormatter $threadItemFormatter
*/
public function __construct(
ThreadItemStore $threadItemStore,
ThreadItemFormatter $threadItemFormatter
) {
parent::__construct( 'FindComment' );
$this->threadItemStore = $threadItemStore;
$this->threadItemFormatter = $threadItemFormatter;
}
/**
* @inheritDoc
*/
protected function getFormFields() {
return [
'idorname' => [
'label-message' => 'discussiontools-findcomment-label-idorname',
'name' => 'idorname',
'type' => 'text',
'default' => $this->par,
],
];
}
/**
* @inheritDoc
*/
protected function getDisplayFormat() {
return 'ooui';
}
/**
* @inheritDoc
*/
protected function alterForm( HTMLForm $form ) {
$form->setMethod( 'GET' );
$form->setWrapperLegend( true );
$form->setSubmitTextMsg( 'discussiontools-findcomment-label-search' );
// Remove subpage when submitting
$form->setTitle( $this->getPageTitle() );
}
private $idOrName;
/**
* @inheritDoc
*/
public function onSubmit( array $data ) {
$this->idOrName = $data['idorname'];
// Always display the form again
return false;
}
/**
* @inheritDoc
*/
public function execute( $par ) {
parent::execute( $par );
$out = $this->getOutput();
$results = false;
if ( $this->idOrName ) {
$byId = $this->threadItemStore->findNewestRevisionsById( $this->idOrName );
if ( $byId ) {
$this->displayItems( $byId, 'discussiontools-findcomment-results-id' );
$results = true;
}
$byName = $this->threadItemStore->findNewestRevisionsByName( $this->idOrName );
if ( $byName ) {
$this->displayItems( $byName, 'discussiontools-findcomment-results-name' );
$results = true;
}
}
if ( $results ) {
$out->addHTML( Html::rawElement( 'p', [],
$this->msg( 'discussiontools-findcomment-gotocomment', $this->idOrName )->parse() ) );
} elseif ( $this->idOrName ) {
$out->addHTML( Html::rawElement( 'p', [],
$this->msg( 'discussiontools-findcomment-noresults' )->parse() ) );
}
}
/**
* @param array $threadItems
* @param string $msgKey
*/
private function displayItems( array $threadItems, string $msgKey ) {
$out = $this->getOutput();
$list = [];
foreach ( $threadItems as $item ) {
$line = $this->threadItemFormatter->formatLine( $item, $this );
$list[] = Html::rawElement( 'li', [], $line );
}
$out->addHTML( Html::rawElement( 'p', [], $this->msg( $msgKey, count( $list ) )->parse() ) );
$out->addHTML( Html::rawElement( 'ul', [], implode( '', $list ) ) );
}
/**
* @inheritDoc
*/
public function getDescription() {
return $this->msg( 'discussiontools-findcomment-title' )->text();
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
use RedirectSpecialPage;
use SpecialPage;
use Title;
class SpecialGoToComment extends RedirectSpecialPage {
/** @var ThreadItemStore */
private $threadItemStore;
/**
* @param ThreadItemStore $threadItemStore
*/
public function __construct(
ThreadItemStore $threadItemStore
) {
parent::__construct( 'GoToComment' );
$this->threadItemStore = $threadItemStore;
}
/**
* @inheritDoc
*/
public function getRedirect( $subpage ) {
$results = [];
// Search for all thread items with the given ID or name, returning results from the latest
// revision of each page they appeared on.
//
// If there is exactly one result which is not transcluded from another page and in the current
// revision of its page, redirect to it.
//
// Otherwise, redirect to full search results on Special:FindComment.
if ( $subpage ) {
$threadItems = $this->threadItemStore->findNewestRevisionsById( $subpage );
foreach ( $threadItems as $item ) {
if ( $item->getRevision()->isCurrent() && !is_string( $item->getTranscludedFrom() ) ) {
$results[] = $item;
}
}
$threadItems = $this->threadItemStore->findNewestRevisionsByName( $subpage );
foreach ( $threadItems as $item ) {
if ( $item->getRevision()->isCurrent() && !is_string( $item->getTranscludedFrom() ) ) {
$results[] = $item;
}
}
}
if ( count( $results ) === 1 ) {
return Title::castFromPageIdentity( $results[0]->getPage() )->createFragmentTarget( $results[0]->getId() );
} else {
return SpecialPage::getTitleFor( 'FindComment', $subpage ?: false );
}
}
}

View file

@ -3,6 +3,8 @@
namespace MediaWiki\Extension\DiscussionTools\ThreadItem;
use DateTimeImmutable;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Revision\RevisionRecord;
class DatabaseCommentItem extends DatabaseThreadItem implements CommentItem {
use CommentItemTrait {
@ -16,6 +18,8 @@ class DatabaseCommentItem extends DatabaseThreadItem implements CommentItem {
private $author;
/**
* @param ProperPageIdentity $page
* @param RevisionRecord $rev
* @param string $name
* @param string $id
* @param DatabaseThreadItem|null $parent
@ -25,10 +29,11 @@ class DatabaseCommentItem extends DatabaseThreadItem implements CommentItem {
* @param string $author
*/
public function __construct(
ProperPageIdentity $page, RevisionRecord $rev,
string $name, string $id, ?DatabaseThreadItem $parent, $transcludedFrom, int $level,
string $timestamp, string $author
) {
parent::__construct( 'comment', $name, $id, $parent, $transcludedFrom, $level );
parent::__construct( $page, $rev, 'comment', $name, $id, $parent, $transcludedFrom, $level );
$this->timestamp = $timestamp;
$this->author = $author;
}

View file

@ -2,6 +2,9 @@
namespace MediaWiki\Extension\DiscussionTools\ThreadItem;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Revision\RevisionRecord;
class DatabaseHeadingItem extends DatabaseThreadItem implements HeadingItem {
use HeadingItemTrait;
@ -14,6 +17,8 @@ class DatabaseHeadingItem extends DatabaseThreadItem implements HeadingItem {
private const PLACEHOLDER_HEADING_LEVEL = 99;
/**
* @param ProperPageIdentity $page
* @param RevisionRecord $rev
* @param string $name
* @param string $id
* @param DatabaseThreadItem|null $parent
@ -22,10 +27,11 @@ class DatabaseHeadingItem extends DatabaseThreadItem implements HeadingItem {
* @param ?int $headingLevel Heading level (1-6). Use null for a placeholder heading.
*/
public function __construct(
ProperPageIdentity $page, RevisionRecord $rev,
string $name, string $id, ?DatabaseThreadItem $parent, $transcludedFrom, int $level,
?int $headingLevel
) {
parent::__construct( 'heading', $name, $id, $parent, $transcludedFrom, $level );
parent::__construct( $page, $rev, 'heading', $name, $id, $parent, $transcludedFrom, $level );
$this->placeholderHeading = $headingLevel === null;
$this->headingLevel = $this->placeholderHeading ? static::PLACEHOLDER_HEADING_LEVEL : $headingLevel;
}

View file

@ -3,10 +3,16 @@
namespace MediaWiki\Extension\DiscussionTools\ThreadItem;
use JsonSerializable;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Revision\RevisionRecord;
class DatabaseThreadItem implements JsonSerializable, ThreadItem {
use ThreadItemTrait;
/** @var ProperPageIdentity */
private $page;
/** @var RevisionRecord */
private $rev;
/** @var string */
private $type;
/** @var string */
@ -23,6 +29,8 @@ class DatabaseThreadItem implements JsonSerializable, ThreadItem {
private $level;
/**
* @param ProperPageIdentity $page
* @param RevisionRecord $rev
* @param string $type
* @param string $name
* @param string $id
@ -31,8 +39,11 @@ class DatabaseThreadItem implements JsonSerializable, ThreadItem {
* @param int $level
*/
public function __construct(
ProperPageIdentity $page, RevisionRecord $rev,
string $type, string $name, string $id, ?DatabaseThreadItem $parent, $transcludedFrom, int $level
) {
$this->page = $page;
$this->rev = $rev;
$this->name = $name;
$this->id = $id;
$this->type = $type;
@ -41,6 +52,20 @@ class DatabaseThreadItem implements JsonSerializable, ThreadItem {
$this->level = $level;
}
/**
* @return ProperPageIdentity
*/
public function getPage(): ProperPageIdentity {
return $this->page;
}
/**
* @return RevisionRecord
*/
public function getRevision(): RevisionRecord {
return $this->rev;
}
/**
* @inheritDoc
*/

View file

@ -0,0 +1,76 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem;
use MediaWiki\Linker\LinkRenderer;
use MessageLocalizer;
use TitleFormatter;
use TitleValue;
/**
* Displays links to comments and headings represented as ThreadItems.
*/
class ThreadItemFormatter {
/** @var TitleFormatter */
private $titleFormatter;
/** @var LinkRenderer */
private $linkRenderer;
/**
* @param TitleFormatter $titleFormatter
* @param LinkRenderer $linkRenderer
*/
public function __construct(
TitleFormatter $titleFormatter,
LinkRenderer $linkRenderer
) {
$this->titleFormatter = $titleFormatter;
$this->linkRenderer = $linkRenderer;
}
/**
* Make a link to a thread item on the page.
*
* @param DatabaseThreadItem $item
* @return string
*/
public function makeLink( DatabaseThreadItem $item ): string {
$title = TitleValue::newFromPage( $item->getPage() )->createFragmentTarget( $item->getId() );
$query = [];
if ( !$item->getRevision()->isCurrent() ) {
$query['oldid'] = $item->getRevision()->getId();
}
$text = $this->titleFormatter->getPrefixedText( $title );
$link = $this->linkRenderer->makeLink( $title, $text, [], $query );
return $link;
}
/**
* Make a link to a thread item on the page, with additional information (used on special pages).
*
* @param DatabaseThreadItem $item
* @param MessageLocalizer $context
* @return string
*/
public function formatLine( DatabaseThreadItem $item, MessageLocalizer $context ): string {
$contents = [];
$contents[] = $this->makeLink( $item );
if ( !$item->getRevision()->isCurrent() ) {
$contents[] = $context->msg( 'discussiontools-findcomment-results-notcurrent' )->escaped();
}
if ( is_string( $item->getTranscludedFrom() ) ) {
$contents[] = $context->msg( 'discussiontools-findcomment-results-transcluded' )->escaped();
}
return implode( $context->msg( 'word-separator' )->escaped(), $contents );
}
}

View file

@ -0,0 +1,569 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
use ConfigFactory;
use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseCommentItem;
use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseHeadingItem;
use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem;
use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem;
use MediaWiki\Page\PageStore;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\User\ActorStore;
use MWTimestamp;
use ReadOnlyMode;
use stdClass;
use TitleFormatter;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\IMaintainableDatabase;
use Wikimedia\Rdbms\IResultWrapper;
use Wikimedia\Rdbms\SelectQueryBuilder;
/**
* Stores and fetches ThreadItemSets from the database.
*/
class ThreadItemStore {
/** @var ConfigFactory */
private $configFactory;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var PageStore */
private $pageStore;
/** @var RevisionStore */
private $revStore;
/** @var TitleFormatter */
private $titleFormatter;
/** @var ActorStore */
private $actorStore;
/**
* @param ConfigFactory $configFactory
* @param ILBFactory $lbFactory
* @param ReadOnlyMode $readOnlyMode
* @param PageStore $pageStore
* @param RevisionStore $revStore
* @param TitleFormatter $titleFormatter
* @param ActorStore $actorStore
*/
public function __construct(
ConfigFactory $configFactory,
ILBFactory $lbFactory,
ReadOnlyMode $readOnlyMode,
PageStore $pageStore,
RevisionStore $revStore,
TitleFormatter $titleFormatter,
ActorStore $actorStore
) {
$this->configFactory = $configFactory;
$this->loadBalancer = $lbFactory->getMainLB();
$this->readOnlyMode = $readOnlyMode;
$this->pageStore = $pageStore;
$this->revStore = $revStore;
$this->titleFormatter = $titleFormatter;
$this->actorStore = $actorStore;
}
/**
* Returns true if the tables necessary for this feature haven't been created yet,
* to allow failing softly in that case.
*
* @return bool
*/
private function isDisabled(): bool {
static $tablesCreated = null;
if ( $tablesCreated === null ) {
$tablesCreated = $this->getConnectionRef( DB_REPLICA )->tableExists( 'discussiontools_items', __METHOD__ );
}
return !$tablesCreated;
}
/**
* @param int $dbIndex DB_PRIMARY or DB_REPLICA
*
* @return IMaintainableDatabase
*/
private function getConnectionRef( int $dbIndex ): IMaintainableDatabase {
return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
}
/**
* Find the thread items with the given name in the newest revision of every page in which they
* have appeared.
*
* @param string|string[] $itemName
* @return DatabaseThreadItem[]
*/
public function findNewestRevisionsByName( $itemName ): array {
if ( $this->isDisabled() ) {
return [];
}
$queryBuilder = $this->getIdsNamesBuilder()
->where( [
'it_itemname' => $itemName,
// Disallow querying for headings of sections that contain no comments.
// They all share the same name, so this would return a huge useless list on most wikis.
// (But we still store them, as we might need this data elsewhere.)
"it_itemname != 'h-'",
] );
$result = $this->fetchItemsResultSet( $queryBuilder );
$revs = $this->fetchRevisionAndPageForItems( $result );
$threadItems = [];
foreach ( $result as $row ) {
$threadItem = $this->getThreadItemFromRow( $row, null, $revs );
if ( $threadItem ) {
$threadItems[] = $threadItem;
}
}
return $threadItems;
}
/**
* Find the thread items with the given ID in the newest revision of every page in which they have
* appeared.
*
* @param string|string[] $itemId
* @return DatabaseThreadItem[]
*/
public function findNewestRevisionsById( $itemId ): array {
if ( $this->isDisabled() ) {
return [];
}
$queryBuilder = $this->getIdsNamesBuilder();
// First find the name associated with the ID; then find by name. Otherwise we wouldn't find the
// latest revision in case comment ID changed, e.g. the comment was moved elsewhere on the page.
$itemNameQueryBuilder = $this->getIdsNamesBuilder()
->where( [ 'itid_itemid' => $itemId ] )
->field( 'it_itemname' );
// I think there may be more than 1 only in case of headings?
// For comments, any ID corresponds to just 1 name.
// Not sure how bad it is to not have limit( 1 ) here?
// It might scan a bunch of rows...
// ->limit( 1 );
$queryBuilder
->where( [
'it_itemname IN (' . $itemNameQueryBuilder->getSQL() . ')',
"it_itemname != 'h-'",
] );
$result = $this->fetchItemsResultSet( $queryBuilder );
$revs = $this->fetchRevisionAndPageForItems( $result );
$threadItems = [];
foreach ( $result as $row ) {
$threadItem = $this->getThreadItemFromRow( $row, null, $revs );
if ( $threadItem ) {
$threadItems[] = $threadItem;
}
}
return $threadItems;
}
/**
* @param SelectQueryBuilder $queryBuilder
* @return IResultWrapper
*/
private function fetchItemsResultSet( SelectQueryBuilder $queryBuilder ): IResultWrapper {
$queryBuilder
->fields( [
'itr_id',
'it_itemname',
'it_timestamp',
'it_actor',
'itid_itemid',
'itr_parent_id',
'itr_transcludedfrom',
'itr_level',
'itr_headinglevel',
'itr_revision_id',
] )
// PageStore fields for the transcluded-from page
->leftJoin( 'page', null, [ 'page_id = itr_transcludedfrom' ] )
->fields( $this->pageStore->getSelectFields() )
// ActorStore fields for the author
->leftJoin( 'actor', null, [ 'actor_id = it_actor' ] )
->fields( [ 'actor_id', 'actor_name', 'actor_user' ] )
// Parent item ID (the string, not just the primary key)
->leftJoin(
$this->getIdsNamesBuilder()
->fields( [
'itr_parent__itr_id' => 'itr_id',
'itr_parent__itid_itemid' => 'itid_itemid',
] ),
null,
[ 'itr_parent_id = itr_parent__itr_id' ]
)
->field( 'itr_parent__itid_itemid' );
return $queryBuilder->fetchResultSet();
}
/**
* @param IResultWrapper $result
* @return stdClass[]
*/
private function fetchRevisionAndPageForItems( IResultWrapper $result ): array {
// This could theoretically be done in the same query as fetchItemsResultSet(),
// but the resulting query would be two screens long
// and we'd have to alias a lot of fields to avoid conflicts.
$revs = [];
foreach ( $result as $row ) {
$revs[ $row->itr_revision_id ] = null;
}
$revQueryBuilder = $this->getConnectionRef( DB_REPLICA )->newSelectQueryBuilder()
->queryInfo( $this->revStore->getQueryInfo( [ 'page' ] ) )
->fields( $this->pageStore->getSelectFields() )
->where( $revs ? [ 'rev_id' => array_keys( $revs ) ] : '0=1' );
$revResult = $revQueryBuilder->fetchResultSet();
foreach ( $revResult as $row ) {
$revs[ $row->rev_id ] = $row;
}
return $revs;
}
/**
* @param stdClass $row
* @param DatabaseThreadItemSet|null $set
* @param array $revs
* @return DatabaseThreadItem|null
*/
private function getThreadItemFromRow(
stdClass $row, ?DatabaseThreadItemSet $set, array $revs
): ?DatabaseThreadItem {
if ( $revs[ $row->itr_revision_id ] === null ) {
// We didn't find the 'revision' table row at all, this revision is deleted.
// (The page may or may not have other non-deleted revisions.)
// Pretend the thread item doesn't exist to avoid leaking data to users who shouldn't see it.
// TODO Allow privileged users to see it (we'd need to query from 'archive')
return null;
}
$revRow = $revs[$row->itr_revision_id];
$page = $this->pageStore->newPageRecordFromRow( $revRow );
$rev = $this->revStore->newRevisionFromRow( $revRow );
if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
// This revision is revision-deleted.
// TODO Allow privileged users to see it
return null;
}
if ( $set && $row->itr_parent__itid_itemid ) {
$parent = $set->findCommentById( $row->itr_parent__itid_itemid );
} else {
$parent = null;
}
$transcludedFrom = $row->itr_transcludedfrom === null ? false : (
$row->itr_transcludedfrom === '0' ? true :
$this->titleFormatter->getPrefixedText(
$this->pageStore->newPageRecordFromRow( $row )
)
);
if ( $row->it_timestamp !== null && $row->it_actor !== null ) {
$author = $this->actorStore->newActorFromRow( $row )->getName();
$item = new DatabaseCommentItem(
$page,
$rev,
$row->it_itemname,
$row->itid_itemid,
$parent,
$transcludedFrom,
(int)$row->itr_level,
$row->it_timestamp,
$author
);
} else {
$item = new DatabaseHeadingItem(
$page,
$rev,
$row->it_itemname,
$row->itid_itemid,
$parent,
$transcludedFrom,
(int)$row->itr_level,
$row->itr_headinglevel === null ? null : (int)$row->itr_headinglevel
);
}
if ( $parent ) {
$parent->addReply( $item );
}
return $item;
}
/**
* Find the thread item set for the given revision, assuming that it is the current revision of
* its page.
*
* @param int $revId
* @return DatabaseThreadItemSet
*/
public function findThreadItemsInCurrentRevision( int $revId ): DatabaseThreadItemSet {
if ( $this->isDisabled() ) {
return new DatabaseThreadItemSet();
}
$queryBuilder = $this->getIdsNamesBuilder();
$queryBuilder
->where( [ 'itr_revision_id' => $revId ] )
// We must process parents before their children in the loop later
->orderBy( 'itr_id', SelectQueryBuilder::SORT_ASC );
$result = $this->fetchItemsResultSet( $queryBuilder );
$revs = $this->fetchRevisionAndPageForItems( $result );
$set = new DatabaseThreadItemSet();
foreach ( $result as $row ) {
$threadItem = $this->getThreadItemFromRow( $row, $set, $revs );
if ( $threadItem ) {
$set->addThreadItem( $threadItem );
$set->updateIdAndNameMaps( $threadItem );
}
}
return $set;
}
/**
* @return SelectQueryBuilder
*/
private function getIdsNamesBuilder(): SelectQueryBuilder {
$dbr = $this->getConnectionRef( DB_REPLICA );
$queryBuilder = $dbr->newSelectQueryBuilder()
->from( 'discussiontools_items' )
->join( 'discussiontools_item_pages', null, [ 'itp_items_id = it_id' ] )
->join( 'discussiontools_item_revisions', null, [
'itr_items_id = it_id',
// Only the latest revision of the items with each name
'itr_revision_id = itp_newest_revision_id',
] )
->join( 'discussiontools_item_ids', null, [ 'itid_id = itr_itemid_id' ] );
return $queryBuilder;
}
/**
* Store the thread item set.
*
* @param RevisionRecord $rev
* @param ThreadItemSet $threadItemSet
* @return bool
*/
public function insertThreadItems( RevisionRecord $rev, ThreadItemSet $threadItemSet ): bool {
if ( $this->isDisabled() || $this->readOnlyMode->isReadOnly() ) {
return false;
}
$dbw = $this->getConnectionRef( DB_PRIMARY );
$didInsert = false;
$method = __METHOD__;
$dbw->doAtomicSection( $method, function ( $dbw ) use ( $method, $rev, $threadItemSet, &$didInsert ) {
$itemRevisionsIds = [];
foreach ( $threadItemSet->getThreadItems() as $item ) {
$itemIdsId = $dbw->selectField(
'discussiontools_item_ids',
'itid_id',
[ 'itid_itemid' => $item->getId() ],
$method
);
if ( $itemIdsId === false ) {
$dbw->insert(
'discussiontools_item_ids',
[
'itid_itemid' => $item->getId(),
],
$method
);
$itemIdsId = $dbw->insertId();
$didInsert = true;
}
$itemsId = $dbw->selectField(
'discussiontools_items',
'it_id',
[ 'it_itemname' => $item->getName() ],
$method
);
if ( $itemsId === false ) {
$dbw->insert(
'discussiontools_items',
[
'it_itemname' => $item->getName(),
] +
( $item instanceof CommentItem ? [
'it_timestamp' =>
$dbw->timestamp( $item->getTimestampString() ),
'it_actor' =>
$this->actorStore->findActorIdByName( $item->getAuthor(), $dbw ),
] : [] ),
$method
);
$itemsId = $dbw->insertId();
$didInsert = true;
}
$itemRevisionsId = $dbw->selectField(
'discussiontools_item_revisions',
'itr_id',
[
'itr_itemid_id' => $itemIdsId,
'itr_revision_id' => $rev->getId(),
],
$method
);
if ( $itemRevisionsId === false ) {
$transcl = $item->getTranscludedFrom();
$dbw->insert(
'discussiontools_item_revisions',
[
'itr_itemid_id' => $itemIdsId,
'itr_revision_id' => $rev->getId(),
'itr_items_id' => $itemsId,
'itr_parent_id' =>
// This assumes that parent items were processed first
$item->getParent() ? $itemRevisionsIds[ $item->getParent()->getId() ] : null,
'itr_transcludedfrom' =>
$transcl === false ? null : (
$transcl === true ? 0 :
$this->pageStore->getPageByText( $transcl )->getId()
),
'itr_level' => $item->getLevel(),
] +
( $item instanceof HeadingItem ? [
'itr_headinglevel' => $item->isPlaceholderHeading() ? null : $item->getHeadingLevel(),
] : [] ),
$method
);
$itemRevisionsId = $dbw->insertId();
$didInsert = true;
}
$itemRevisionsIds[ $item->getId() ] = $itemRevisionsId;
// Update (or insert) the references to oldest/newest item revision.
// The page revision we're processing is usually the newest one, but it doesn't have to be
// (in case of backfilling using the maintenance script, or in case of revisions being
// imported), so we need all these funky queries to see if we need to update oldest/newest.
// This should be a single upsert query (INSERT ... ON DUPLICATE KEY UPDATE), however it
// doesn't work in practice:
//
// - Attempt 1:
// https://gerrit.wikimedia.org/r/c/mediawiki/extensions/DiscussionTools/+/771974/14/includes/ThreadItemStore.php#451
//
// This is the same logic as below in SQL: only doing a single comparison of the timestamp
// of the current revision to the existing data in discussiontools_item_pages.
// It worked great on my machine, `mysql --version`:
// mysql Ver 8.0.29-0ubuntu0.20.04.3 for Linux on x86_64 ((Ubuntu))
// but it failed in Wikimedia CI, `mysql --version`:
// mysql Ver 15.1 Distrib 10.3.34-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2
// …with the error:
// "Error 1054: Unknown column 'itp_oldest_revision_id' in 'where clause'".
// Apparently it doesn't like dependent subqueries in the UPDATE part of an upsert.
// I'm not sure if it's a bug in MariaDB or if you're not supposed to do that.
//
// - Attempt 2:
// https://gerrit.wikimedia.org/r/c/mediawiki/extensions/DiscussionTools/+/771974/15/includes/ThreadItemStore.php#451
//
// This avoids the dependent subquery: instead of comparing to the existing data in
// discussiontools_item_pages, it just takes the IDs with min/max timestamp from
// discussiontools_item_revisions/revision. This should be a simple lookup from an index,
// but apparently it doesn't work that way and is significantly slower (the maintenance
// script went from 13 minutes to 18 minutes when processing a few thousands of revisions
// on my local testing wiki).
//
// In the end, the solution below using multiple queries is just as fast as the original,
// and only a little more verbose.
$itemPagesRow = $dbw->newSelectQueryBuilder()
->from( 'discussiontools_item_pages' )
->join( 'revision', 'revision_oldest', [ 'itp_oldest_revision_id = revision_oldest.rev_id' ] )
->join( 'revision', 'revision_newest', [ 'itp_newest_revision_id = revision_newest.rev_id' ] )
->field( 'itp_id' )
->field( 'revision_oldest.rev_timestamp', 'oldest_rev_timestamp' )
->field( 'revision_newest.rev_timestamp', 'newest_rev_timestamp' )
->where( [
'itp_items_id' => $itemsId,
'itp_page_id' => $rev->getPageId(),
] )
->fetchRow();
if ( $itemPagesRow === false ) {
$dbw->insert(
'discussiontools_item_pages',
[
'itp_items_id' => $itemsId,
'itp_page_id' => $rev->getPageId(),
'itp_oldest_revision_id' => $rev->getId(),
'itp_newest_revision_id' => $rev->getId(),
],
$method
);
} else {
$existingTime = ( new MWTimestamp( $itemPagesRow->oldest_rev_timestamp ) )->getTimestamp( TS_MW );
if ( $existingTime >= $rev->getTimestamp() ) {
$dbw->update(
'discussiontools_item_pages',
[ 'itp_oldest_revision_id' => $rev->getId() ],
[ 'itp_id' => $itemPagesRow->itp_id ],
$method
);
}
$existingTime = ( new MWTimestamp( $itemPagesRow->newest_rev_timestamp ) )->getTimestamp( TS_MW );
if ( $existingTime <= $rev->getTimestamp() ) {
$dbw->update(
'discussiontools_item_pages',
[ 'itp_newest_revision_id' => $rev->getId() ],
[ 'itp_id' => $itemPagesRow->itp_id ],
$method
);
}
}
// Delete rows that we don't care about, to save space (item revisions with the same ID and
// name as the one we just inserted, which are not the oldest or newest revision).
$oldestRevisionSql = $dbw->selectSQLText(
'discussiontools_item_pages',
'itp_oldest_revision_id',
[ 'itp_items_id' => $itemsId ],
$method
);
$newestRevisionSql = $dbw->selectSQLText(
'discussiontools_item_pages',
'itp_newest_revision_id',
[ 'itp_items_id' => $itemsId ],
$method
);
$dbw->delete(
'discussiontools_item_revisions',
[
'itr_itemid_id' => $itemIdsId,
'itr_items_id' => $itemsId,
"itr_revision_id NOT IN ($oldestRevisionSql)",
"itr_revision_id NOT IN ($newestRevisionSql)",
],
$method
);
}
}, $dbw::ATOMIC_CANCELABLE );
return $didInsert;
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace MediaWiki\Extension\DiscussionTools\Maintenance;
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
use MediaWiki\Extension\DiscussionTools\ThreadItemStore;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionStore;
use MWExceptionRenderer;
use stdClass;
use TableCleanup;
use Throwable;
use Title;
// Security: Disable all stream wrappers and reenable individually as needed
foreach ( stream_get_wrappers() as $wrapper ) {
stream_wrapper_unregister( $wrapper );
}
stream_wrapper_restore( 'file' );
$basePath = getenv( 'MW_INSTALL_PATH' );
if ( $basePath ) {
if ( !is_dir( $basePath )
|| strpos( $basePath, '.' ) !== false
|| strpos( $basePath, '~' ) !== false
) {
die( "Bad MediaWiki install path: $basePath\n" );
}
} else {
$basePath = __DIR__ . '/../../..';
}
require_once "$basePath/maintenance/Maintenance.php";
// Autoloader isn't set up yet until we do `require_once RUN_MAINTENANCE_IF_MAIN`…
// but our class needs to exist at that point D:
require_once "$basePath/maintenance/TableCleanup.php";
class PersistRevisionThreadItems extends TableCleanup {
/** @var ThreadItemStore */
private $itemStore;
/** @var RevisionStore */
private $revStore;
public function __construct() {
parent::__construct();
$this->requireExtension( 'DiscussionTools' );
$this->addDescription( 'Persist thread item information for the given pages/revisions' );
$this->addOption( 'rev', 'Revision ID to process', false, true, false, true );
$this->addOption( 'page', 'Page title to process', false, true, false, true );
$this->addOption( 'all', 'Process the whole wiki', false, false, false, false );
}
public function execute() {
$services = MediaWikiServices::getInstance();
$this->itemStore = $services->getService( 'DiscussionTools.ThreadItemStore' );
$this->revStore = $services->getRevisionStore();
if ( $this->getOption( 'all' ) ) {
$conds = [];
} elseif ( $this->getOption( 'page' ) ) {
$linkBatch = $services->getLinkBatchFactory()->newLinkBatch();
foreach ( $this->getOption( 'page' ) as $page ) {
$linkBatch->addObj( Title::newFromText( $page ) );
}
$pageIds = array_map( static function ( $page ) {
return $page->getId();
}, $linkBatch->getPageIdentities() );
$conds = [ 'rev_page' => $pageIds ];
} elseif ( $this->getOption( 'rev' ) ) {
$conds = [ 'rev_id' => $this->getOption( 'rev' ) ];
} else {
$this->error( "One of 'all', 'page', or 'rev' required" );
$this->maybeHelp( true );
return;
}
$this->runTable( [
'table' => 'revision',
'conds' => $conds,
'index' => [ 'rev_page', 'rev_timestamp', 'rev_id' ],
'callback' => 'processRow',
] );
}
/**
* @param stdClass $row Database table row
*/
protected function processRow( stdClass $row ) {
$changed = false;
try {
// HACK (because we don't query the table this data ordinarily comes from,
// and we don't care about edit summaries here)
$row->rev_comment_text = '';
$row->rev_comment_data = null;
$row->rev_comment_cid = null;
$rev = $this->revStore->newRevisionFromRow( $row );
$title = Title::newFromLinkTarget(
$rev->getPageAsLinkTarget()
);
if ( HookUtils::isAvailableForTitle( $title ) ) {
$threadItemSet = HookUtils::parseRevisionParsoidHtml( $rev );
if ( !$this->dryrun ) {
// Store permalink data
$changed = $this->itemStore->insertThreadItems( $rev, $threadItemSet );
}
}
} catch ( Throwable $e ) {
$this->output( "Error while processing revid=$row->rev_id, pageid=$row->rev_page\n" );
MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
}
$this->progress( (int)$changed );
}
}
$maintClass = PersistRevisionThreadItems::class;
require_once RUN_MAINTENANCE_IF_MAIN;

View file

@ -0,0 +1,260 @@
[
{
"name": "discussiontools_items",
"columns": [
{
"name": "it_id",
"type": "integer",
"options": {
"autoincrement": true,
"unsigned": true,
"notnull": true
}
},
{
"name": "it_itemname",
"comment": "Internal name used to identify this item across all pages and revisions where it might appear, see CommentParser::computeName()",
"type": "binary",
"options": {
"notnull": true,
"length": 255
}
},
{
"name": "it_timestamp",
"comment": "Date and time from signature, or null for headings",
"type": "mwtimestamp",
"options": {
"notnull": false
}
},
{
"name": "it_actor",
"comment": "Author from signature, or null for headings. (key to actor.actor_id)",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": false
}
}
],
"indexes": [
{
"name": "it_itemname",
"comment": "",
"columns": [
"it_itemname"
],
"unique": true
}
],
"pk": [
"it_id"
]
},
{
"name": "discussiontools_item_ids",
"columns": [
{
"name": "itid_id",
"type": "integer",
"options": {
"autoincrement": true,
"unsigned": true,
"notnull": true
}
},
{
"name": "itid_itemid",
"comment": "Internal ID used to identify this item in this revision, see CommentParser::computeId()",
"type": "binary",
"options": {
"notnull": true,
"length": 255
}
}
],
"indexes": [
{
"name": "itid_itemid",
"comment": "",
"columns": [
"itid_itemid"
],
"unique": true
}
],
"pk": [
"itid_id"
]
},
{
"name": "discussiontools_item_pages",
"comment": "Data in this table is redundant to discussiontools_item_revisions, but if we want to find all pages where a given comment has appeared, querying it from that table would require some awful joins and be expensive",
"columns": [
{
"name": "itp_id",
"type": "integer",
"options": {
"autoincrement": true,
"unsigned": true,
"notnull": true
}
},
{
"name": "itp_items_id",
"comment": "(key to discussiontools_items.it_id)",
"type": "integer",
"options": {
"unsigned": true,
"notnull": true
}
},
{
"name": "itp_page_id",
"comment": "(key to page.page_id)",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": true
}
},
{
"name": "itp_oldest_revision_id",
"comment": "(key to revision.rev_id)",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": true
}
},
{
"name": "itp_newest_revision_id",
"comment": "(key to revision.rev_id)",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": true
}
}
],
"indexes": [
{
"name": "itp_items_id_page_id",
"comment": "insertThreadItems()",
"columns": [
"itp_items_id",
"itp_page_id"
],
"unique": true
},
{
"name": "itp_items_id_newest_revision_id",
"comment": "findNewestRevisionsByName(), findNewestRevisionsById()",
"columns": [
"itp_items_id",
"itp_newest_revision_id"
],
"unique": true
}
],
"pk": [
"itp_id"
]
},
{
"name": "discussiontools_item_revisions",
"comment": "",
"columns": [
{
"name": "itr_id",
"type": "integer",
"options": {
"autoincrement": true,
"unsigned": true,
"notnull": true
}
},
{
"name": "itr_itemid_id",
"comment": "(key to discussiontools_item_ids.itid_id)",
"type": "integer",
"options": {
"notnull": true
}
},
{
"name": "itr_revision_id",
"comment": "(key to revision.rev_id)",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": true
}
},
{
"name": "itr_items_id",
"comment": "(key to discussiontools_items.it_id)",
"type": "integer",
"options": {
"notnull": true
}
},
{
"name": "itr_parent_id",
"comment": "(key to discussiontools_item_revisions.itr_id)",
"type": "integer",
"options": {
"notnull": false
}
},
{
"name": "itr_transcludedfrom",
"comment": "Page where this item is transcluded from (null: not transcluded, 0: transcluded from unknown page) (key to page.page_id)",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": false
}
},
{
"name": "itr_level",
"comment": "Indentation level (0 for headings, 1+ for comments)",
"type": "mwtinyint",
"options": {
"notnull": true
}
},
{
"name": "itr_headinglevel",
"comment": "Heading level (1-6), or null for placeholder headings and comments",
"type": "mwtinyint",
"options": {
"notnull": false
}
}
],
"indexes": [
{
"name": "itr_revision_id",
"comment": "findThreadItemsInCurrentRevision()",
"columns": [
"itr_revision_id"
],
"unique": false
},
{
"name": "itr_itemid_id_revision_id",
"comment": "findNewestRevisionsByName(), findNewestRevisionsById(), insertThreadItems()",
"columns": [
"itr_itemid_id",
"itr_revision_id"
],
"unique": true
}
],
"pk": [
"itr_id"
]
}
]

View file

@ -0,0 +1,49 @@
-- This file is automatically generated using maintenance/generateSchemaSql.php.
-- Source: sql/discussiontools_persistent.json
-- Do not modify this file directly.
-- See https://www.mediawiki.org/wiki/Manual:Schema_changes
CREATE TABLE /*_*/discussiontools_items (
it_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
it_itemname VARBINARY(255) NOT NULL,
it_timestamp BINARY(14) DEFAULT NULL,
it_actor BIGINT UNSIGNED DEFAULT NULL,
UNIQUE INDEX it_itemname (it_itemname),
PRIMARY KEY(it_id)
) /*$wgDBTableOptions*/;
CREATE TABLE /*_*/discussiontools_item_ids (
itid_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
itid_itemid VARBINARY(255) NOT NULL,
UNIQUE INDEX itid_itemid (itid_itemid),
PRIMARY KEY(itid_id)
) /*$wgDBTableOptions*/;
CREATE TABLE /*_*/discussiontools_item_pages (
itp_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
itp_items_id INT UNSIGNED NOT NULL,
itp_page_id BIGINT UNSIGNED NOT NULL,
itp_oldest_revision_id BIGINT UNSIGNED NOT NULL,
itp_newest_revision_id BIGINT UNSIGNED NOT NULL,
UNIQUE INDEX itp_items_id_page_id (itp_items_id, itp_page_id),
UNIQUE INDEX itp_items_id_newest_revision_id (
itp_items_id, itp_newest_revision_id
),
PRIMARY KEY(itp_id)
) /*$wgDBTableOptions*/;
CREATE TABLE /*_*/discussiontools_item_revisions (
itr_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
itr_itemid_id INT NOT NULL,
itr_revision_id BIGINT UNSIGNED NOT NULL,
itr_items_id INT NOT NULL,
itr_parent_id INT DEFAULT NULL,
itr_transcludedfrom BIGINT UNSIGNED DEFAULT NULL,
itr_level TINYINT NOT NULL,
itr_headinglevel TINYINT DEFAULT NULL,
INDEX itr_revision_id (itr_revision_id),
UNIQUE INDEX itr_itemid_id_revision_id (itr_itemid_id, itr_revision_id),
PRIMARY KEY(itr_id)
) /*$wgDBTableOptions*/;

View file

@ -0,0 +1,55 @@
-- This file is automatically generated using maintenance/generateSchemaSql.php.
-- Source: sql/discussiontools_persistent.json
-- Do not modify this file directly.
-- See https://www.mediawiki.org/wiki/Manual:Schema_changes
CREATE TABLE discussiontools_items (
it_id SERIAL NOT NULL,
it_itemname TEXT NOT NULL,
it_timestamp TIMESTAMPTZ DEFAULT NULL,
it_actor BIGINT DEFAULT NULL,
PRIMARY KEY(it_id)
);
CREATE UNIQUE INDEX it_itemname ON discussiontools_items (it_itemname);
CREATE TABLE discussiontools_item_ids (
itid_id SERIAL NOT NULL,
itid_itemid TEXT NOT NULL,
PRIMARY KEY(itid_id)
);
CREATE UNIQUE INDEX itid_itemid ON discussiontools_item_ids (itid_itemid);
CREATE TABLE discussiontools_item_pages (
itp_id SERIAL NOT NULL,
itp_items_id INT NOT NULL,
itp_page_id BIGINT NOT NULL,
itp_oldest_revision_id BIGINT NOT NULL,
itp_newest_revision_id BIGINT NOT NULL,
PRIMARY KEY(itp_id)
);
CREATE UNIQUE INDEX itp_items_id_page_id ON discussiontools_item_pages (itp_items_id, itp_page_id);
CREATE UNIQUE INDEX itp_items_id_newest_revision_id ON discussiontools_item_pages (
itp_items_id, itp_newest_revision_id
);
CREATE TABLE discussiontools_item_revisions (
itr_id SERIAL NOT NULL,
itr_itemid_id INT NOT NULL,
itr_revision_id BIGINT NOT NULL,
itr_items_id INT NOT NULL,
itr_parent_id INT DEFAULT NULL,
itr_transcludedfrom BIGINT DEFAULT NULL,
itr_level SMALLINT NOT NULL,
itr_headinglevel SMALLINT DEFAULT NULL,
PRIMARY KEY(itr_id)
);
CREATE INDEX itr_revision_id ON discussiontools_item_revisions (itr_revision_id);
CREATE UNIQUE INDEX itr_itemid_id_revision_id ON discussiontools_item_revisions (itr_itemid_id, itr_revision_id);

View file

@ -0,0 +1,47 @@
-- This file is automatically generated using maintenance/generateSchemaSql.php.
-- Source: sql/discussiontools_persistent.json
-- Do not modify this file directly.
-- See https://www.mediawiki.org/wiki/Manual:Schema_changes
CREATE TABLE /*_*/discussiontools_items (
it_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
it_itemname BLOB NOT NULL, it_timestamp BLOB DEFAULT NULL,
it_actor BIGINT UNSIGNED DEFAULT NULL
);
CREATE UNIQUE INDEX it_itemname ON /*_*/discussiontools_items (it_itemname);
CREATE TABLE /*_*/discussiontools_item_ids (
itid_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
itid_itemid BLOB NOT NULL
);
CREATE UNIQUE INDEX itid_itemid ON /*_*/discussiontools_item_ids (itid_itemid);
CREATE TABLE /*_*/discussiontools_item_pages (
itp_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
itp_items_id INTEGER UNSIGNED NOT NULL,
itp_page_id BIGINT UNSIGNED NOT NULL,
itp_oldest_revision_id BIGINT UNSIGNED NOT NULL,
itp_newest_revision_id BIGINT UNSIGNED NOT NULL
);
CREATE UNIQUE INDEX itp_items_id_page_id ON /*_*/discussiontools_item_pages (itp_items_id, itp_page_id);
CREATE UNIQUE INDEX itp_items_id_newest_revision_id ON /*_*/discussiontools_item_pages (
itp_items_id, itp_newest_revision_id
);
CREATE TABLE /*_*/discussiontools_item_revisions (
itr_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
itr_itemid_id INTEGER NOT NULL, itr_revision_id BIGINT UNSIGNED NOT NULL,
itr_items_id INTEGER NOT NULL, itr_parent_id INTEGER DEFAULT NULL,
itr_transcludedfrom BIGINT UNSIGNED DEFAULT NULL,
itr_level SMALLINT NOT NULL, itr_headinglevel SMALLINT DEFAULT NULL
);
CREATE INDEX itr_revision_id ON /*_*/discussiontools_item_revisions (itr_revision_id);
CREATE UNIQUE INDEX itr_itemid_id_revision_id ON /*_*/discussiontools_item_revisions (itr_itemid_id, itr_revision_id);

View file

@ -0,0 +1,18 @@
[
{
"itid_id": "1",
"itid_itemid": "h-A-20220720010100"
},
{
"itid_id": "2",
"itid_itemid": "c-X-20220720010100-A"
},
{
"itid_id": "3",
"itid_itemid": "c-Y-20220720010200-X-20220720010100"
},
{
"itid_id": "4",
"itid_itemid": "c-Z-20220720010300-Y-20220720010200"
}
]

View file

@ -0,0 +1,30 @@
[
{
"itp_id": "1",
"itp_items_id": "1",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "4"
},
{
"itp_id": "2",
"itp_items_id": "2",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "4"
},
{
"itp_id": "3",
"itp_items_id": "3",
"itp_page_id": "2",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "4"
},
{
"itp_id": "4",
"itp_items_id": "4",
"itp_page_id": "2",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "4"
}
]

View file

@ -0,0 +1,72 @@
[
{
"itr_id": "1",
"itr_itemid_id": "1",
"itr_revision_id": "2",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "2",
"itr_itemid_id": "2",
"itr_revision_id": "2",
"itr_items_id": "2",
"itr_parent_id": "1",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "5",
"itr_itemid_id": "3",
"itr_revision_id": "3",
"itr_items_id": "3",
"itr_parent_id": "4",
"itr_transcludedfrom": null,
"itr_level": "2",
"itr_headinglevel": null
},
{
"itr_id": "6",
"itr_itemid_id": "1",
"itr_revision_id": "4",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "7",
"itr_itemid_id": "2",
"itr_revision_id": "4",
"itr_items_id": "2",
"itr_parent_id": "6",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "8",
"itr_itemid_id": "3",
"itr_revision_id": "4",
"itr_items_id": "3",
"itr_parent_id": "7",
"itr_transcludedfrom": null,
"itr_level": "2",
"itr_headinglevel": null
},
{
"itr_id": "9",
"itr_itemid_id": "4",
"itr_revision_id": "4",
"itr_items_id": "4",
"itr_parent_id": "8",
"itr_transcludedfrom": null,
"itr_level": "3",
"itr_headinglevel": null
}
]

View file

@ -0,0 +1,26 @@
[
{
"it_id": "1",
"it_itemname": "h-X-20220720010100",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "2",
"it_itemname": "c-X-20220720010100",
"it_timestamp": "20220720010100",
"it_actor": "2"
},
{
"it_id": "3",
"it_itemname": "c-Y-20220720010200",
"it_timestamp": "20220720010200",
"it_actor": "3"
},
{
"it_id": "4",
"it_itemname": "c-Z-20220720010300",
"it_timestamp": "20220720010300",
"it_actor": "4"
}
]

View file

@ -0,0 +1,33 @@
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.11/ http://www.mediawiki.org/xml/export-0.11.xsd" version="0.11" xml:lang="en">
<page>
<title>Talk:ThreadItemStore1</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 01:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T01:01:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Y</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 01:01, 20 July 2022 (UTC)
:C. --[[User:Y]] 01:02, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T01:02:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 01:01, 20 July 2022 (UTC)
:C. --[[User:Y]] 01:02, 20 July 2022 (UTC)
::D. --[[User:Z]] 01:03, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T01:03:00Z</timestamp>
</revision>
</page>
</mediawiki>

View file

@ -0,0 +1,10 @@
[
{
"itid_id": "1",
"itid_itemid": "h-A-20220720020100"
},
{
"itid_id": "2",
"itid_itemid": "c-X-20220720020100-A"
}
]

View file

@ -0,0 +1,30 @@
[
{
"itp_id": "1",
"itp_items_id": "1",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "2"
},
{
"itp_id": "2",
"itp_items_id": "2",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "2"
},
{
"itp_id": "3",
"itp_items_id": "1",
"itp_page_id": "3",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "4"
},
{
"itp_id": "4",
"itp_items_id": "2",
"itp_page_id": "3",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "4"
}
]

View file

@ -0,0 +1,42 @@
[
{
"itr_id": "1",
"itr_itemid_id": "1",
"itr_revision_id": "2",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "2",
"itr_itemid_id": "2",
"itr_revision_id": "2",
"itr_items_id": "2",
"itr_parent_id": "1",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "3",
"itr_itemid_id": "1",
"itr_revision_id": "4",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "4",
"itr_itemid_id": "2",
"itr_revision_id": "4",
"itr_items_id": "2",
"itr_parent_id": "3",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
}
]

View file

@ -0,0 +1,14 @@
[
{
"it_id": "1",
"it_itemname": "h-X-20220720020100",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "2",
"it_itemname": "c-X-20220720020100",
"it_timestamp": "20220720020100",
"it_actor": "2"
}
]

View file

@ -0,0 +1,33 @@
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.11/ http://www.mediawiki.org/xml/export-0.11.xsd" version="0.11" xml:lang="en">
<page>
<title>Talk:ThreadItemStore2</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 02:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T02:01:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve" />
<timestamp>2022-07-20T02:02:00Z</timestamp>
</revision>
</page>
<page>
<title>Talk:ThreadItemStore2/Archive</title>
<ns>1</ns>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 02:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T02:03:00Z</timestamp>
</revision>
</page>
</mediawiki>

View file

@ -0,0 +1,18 @@
[
{
"itid_id": "1",
"itid_itemid": "h-A-20220720030100"
},
{
"itid_id": "2",
"itid_itemid": "c-X-20220720030100-A"
},
{
"itid_id": "3",
"itid_itemid": "h-C-20220720030100"
},
{
"itid_id": "4",
"itid_itemid": "c-X-20220720030100-C"
}
]

View file

@ -0,0 +1,30 @@
[
{
"itp_id": "1",
"itp_items_id": "1",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "2"
},
{
"itp_id": "2",
"itp_items_id": "2",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "2"
},
{
"itp_id": "3",
"itp_items_id": "1",
"itp_page_id": "3",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "3"
},
{
"itp_id": "4",
"itp_items_id": "2",
"itp_page_id": "3",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "3"
}
]

View file

@ -0,0 +1,42 @@
[
{
"itr_id": "1",
"itr_itemid_id": "1",
"itr_revision_id": "2",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "2",
"itr_itemid_id": "2",
"itr_revision_id": "2",
"itr_items_id": "2",
"itr_parent_id": "1",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "3",
"itr_itemid_id": "3",
"itr_revision_id": "3",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "4",
"itr_itemid_id": "4",
"itr_revision_id": "3",
"itr_items_id": "2",
"itr_parent_id": "3",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
}
]

View file

@ -0,0 +1,14 @@
[
{
"it_id": "1",
"it_itemname": "h-X-20220720030100",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "2",
"it_itemname": "c-X-20220720030100",
"it_timestamp": "20220720030100",
"it_actor": "2"
}
]

View file

@ -0,0 +1,26 @@
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.11/ http://www.mediawiki.org/xml/export-0.11.xsd" version="0.11" xml:lang="en">
<page>
<title>Talk:ThreadItemStore3</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 03:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T03:01:00Z</timestamp>
</revision>
</page>
<page>
<title>Talk:ThreadItemStore3b</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== C ==
D. --[[User:X]] 03:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T03:01:01Z</timestamp>
</revision>
</page>
</mediawiki>

View file

@ -0,0 +1,14 @@
[
{
"itid_id": "1",
"itid_itemid": "h-A-20220720040100"
},
{
"itid_id": "2",
"itid_itemid": "c-X-20220720040100-A"
},
{
"itid_id": "3",
"itid_itemid": "c-Y-20220720040200-X-20220720040100"
}
]

View file

@ -0,0 +1,44 @@
[
{
"itp_id": "1",
"itp_items_id": "1",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "3"
},
{
"itp_id": "2",
"itp_items_id": "2",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "3"
},
{
"itp_id": "3",
"itp_items_id": "3",
"itp_page_id": "2",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "3"
},
{
"itp_id": "4",
"itp_items_id": "1",
"itp_page_id": "3",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "4"
},
{
"itp_id": "5",
"itp_items_id": "2",
"itp_page_id": "3",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "4"
},
{
"itp_id": "6",
"itp_items_id": "3",
"itp_page_id": "3",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "4"
}
]

View file

@ -0,0 +1,82 @@
[
{
"itr_id": "1",
"itr_itemid_id": "1",
"itr_revision_id": "2",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "2",
"itr_itemid_id": "2",
"itr_revision_id": "2",
"itr_items_id": "2",
"itr_parent_id": "1",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "3",
"itr_itemid_id": "1",
"itr_revision_id": "3",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "4",
"itr_itemid_id": "2",
"itr_revision_id": "3",
"itr_items_id": "2",
"itr_parent_id": "3",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "5",
"itr_itemid_id": "3",
"itr_revision_id": "3",
"itr_items_id": "3",
"itr_parent_id": "4",
"itr_transcludedfrom": null,
"itr_level": "2",
"itr_headinglevel": null
},
{
"itr_id": "6",
"itr_itemid_id": "1",
"itr_revision_id": "4",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": "2",
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "7",
"itr_itemid_id": "2",
"itr_revision_id": "4",
"itr_items_id": "2",
"itr_parent_id": "6",
"itr_transcludedfrom": "2",
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "8",
"itr_itemid_id": "3",
"itr_revision_id": "4",
"itr_items_id": "3",
"itr_parent_id": "7",
"itr_transcludedfrom": "2",
"itr_level": "2",
"itr_headinglevel": null
}
]

View file

@ -0,0 +1,20 @@
[
{
"it_id": "1",
"it_itemname": "h-X-20220720040100",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "2",
"it_itemname": "c-X-20220720040100",
"it_timestamp": "20220720040100",
"it_actor": "2"
},
{
"it_id": "3",
"it_itemname": "c-Y-20220720040200",
"it_timestamp": "20220720040200",
"it_actor": "3"
}
]

View file

@ -0,0 +1,34 @@
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.11/ http://www.mediawiki.org/xml/export-0.11.xsd" version="0.11" xml:lang="en">
<page>
<title>Talk:ThreadItemStore4/b</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 04:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T04:01:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Y</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 04:01, 20 July 2022 (UTC)
:C. --[[User:Y]] 04:02, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T04:02:00Z</timestamp>
</revision>
</page>
<page>
<title>Talk:ThreadItemStore4</title>
<ns>1</ns>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve">{{Talk:ThreadItemStore4/b}}</text>
<timestamp>2022-07-20T04:03:00Z</timestamp>
</revision>
</page>
</mediawiki>

View file

@ -0,0 +1,22 @@
[
{
"itid_id": "1",
"itid_itemid": "h-A-20220720050100"
},
{
"itid_id": "2",
"itid_itemid": "c-X-20220720050100-A"
},
{
"itid_id": "3",
"itid_itemid": "c-Y-20220720050200-X-20220720050100"
},
{
"itid_id": "4",
"itid_itemid": "c-Z-20220720050300-Y-20220720050200"
},
{
"itid_id": "5",
"itid_itemid": "c-Z-20220720050300-X-20220720050100"
}
]

View file

@ -0,0 +1,30 @@
[
{
"itp_id": "1",
"itp_items_id": "1",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "5"
},
{
"itp_id": "2",
"itp_items_id": "2",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "5"
},
{
"itp_id": "3",
"itp_items_id": "3",
"itp_page_id": "2",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "5"
},
{
"itp_id": "4",
"itp_items_id": "4",
"itp_page_id": "2",
"itp_oldest_revision_id": "4",
"itp_newest_revision_id": "5"
}
]

View file

@ -0,0 +1,82 @@
[
{
"itr_id": "1",
"itr_itemid_id": "1",
"itr_revision_id": "2",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "2",
"itr_itemid_id": "2",
"itr_revision_id": "2",
"itr_items_id": "2",
"itr_parent_id": "1",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "5",
"itr_itemid_id": "3",
"itr_revision_id": "3",
"itr_items_id": "3",
"itr_parent_id": "4",
"itr_transcludedfrom": null,
"itr_level": "2",
"itr_headinglevel": null
},
{
"itr_id": "9",
"itr_itemid_id": "4",
"itr_revision_id": "4",
"itr_items_id": "4",
"itr_parent_id": "8",
"itr_transcludedfrom": null,
"itr_level": "3",
"itr_headinglevel": null
},
{
"itr_id": "10",
"itr_itemid_id": "1",
"itr_revision_id": "5",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "11",
"itr_itemid_id": "2",
"itr_revision_id": "5",
"itr_items_id": "2",
"itr_parent_id": "10",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "12",
"itr_itemid_id": "3",
"itr_revision_id": "5",
"itr_items_id": "3",
"itr_parent_id": "11",
"itr_transcludedfrom": null,
"itr_level": "2",
"itr_headinglevel": null
},
{
"itr_id": "13",
"itr_itemid_id": "5",
"itr_revision_id": "5",
"itr_items_id": "4",
"itr_parent_id": "11",
"itr_transcludedfrom": null,
"itr_level": "2",
"itr_headinglevel": null
}
]

View file

@ -0,0 +1,26 @@
[
{
"it_id": "1",
"it_itemname": "h-X-20220720050100",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "2",
"it_itemname": "c-X-20220720050100",
"it_timestamp": "20220720050100",
"it_actor": "2"
},
{
"it_id": "3",
"it_itemname": "c-Y-20220720050200",
"it_timestamp": "20220720050200",
"it_actor": "3"
},
{
"it_id": "4",
"it_itemname": "c-Z-20220720050300",
"it_timestamp": "20220720050300",
"it_actor": "4"
}
]

View file

@ -0,0 +1,43 @@
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.11/ http://www.mediawiki.org/xml/export-0.11.xsd" version="0.11" xml:lang="en">
<page>
<title>Talk:ThreadItemStore5</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 05:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T05:01:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Y</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 05:01, 20 July 2022 (UTC)
:C. --[[User:Y]] 05:02, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T05:02:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 05:01, 20 July 2022 (UTC)
:C. --[[User:Y]] 05:02, 20 July 2022 (UTC)
::D. --[[User:Z]] 05:03, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T05:03:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 05:01, 20 July 2022 (UTC)
:C. --[[User:Y]] 05:02, 20 July 2022 (UTC)
:D. --[[User:Z]] 05:03, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T05:03:01Z</timestamp>
</revision>
</page>
</mediawiki>

View file

@ -0,0 +1,22 @@
[
{
"itid_id": "1",
"itid_itemid": "h-A-20220720060100"
},
{
"itid_id": "2",
"itid_itemid": "c-X-20220720060100-A"
},
{
"itid_id": "3",
"itid_itemid": "h-C-20220720060200"
},
{
"itid_id": "4",
"itid_itemid": "c-Y-20220720060200-C"
},
{
"itid_id": "5",
"itid_itemid": "h-C-A-20220720060200"
}
]

View file

@ -0,0 +1,30 @@
[
{
"itp_id": "1",
"itp_items_id": "1",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "4"
},
{
"itp_id": "2",
"itp_items_id": "2",
"itp_page_id": "2",
"itp_oldest_revision_id": "2",
"itp_newest_revision_id": "4"
},
{
"itp_id": "3",
"itp_items_id": "3",
"itp_page_id": "2",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "4"
},
{
"itp_id": "4",
"itp_items_id": "4",
"itp_page_id": "2",
"itp_oldest_revision_id": "3",
"itp_newest_revision_id": "4"
}
]

View file

@ -0,0 +1,82 @@
[
{
"itr_id": "1",
"itr_itemid_id": "1",
"itr_revision_id": "2",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "2",
"itr_itemid_id": "2",
"itr_revision_id": "2",
"itr_items_id": "2",
"itr_parent_id": "1",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "5",
"itr_itemid_id": "3",
"itr_revision_id": "3",
"itr_items_id": "3",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "6",
"itr_itemid_id": "4",
"itr_revision_id": "3",
"itr_items_id": "4",
"itr_parent_id": "5",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "7",
"itr_itemid_id": "1",
"itr_revision_id": "4",
"itr_items_id": "1",
"itr_parent_id": null,
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "2"
},
{
"itr_id": "8",
"itr_itemid_id": "2",
"itr_revision_id": "4",
"itr_items_id": "2",
"itr_parent_id": "7",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
},
{
"itr_id": "9",
"itr_itemid_id": "5",
"itr_revision_id": "4",
"itr_items_id": "3",
"itr_parent_id": "7",
"itr_transcludedfrom": null,
"itr_level": "0",
"itr_headinglevel": "3"
},
{
"itr_id": "10",
"itr_itemid_id": "4",
"itr_revision_id": "4",
"itr_items_id": "4",
"itr_parent_id": "9",
"itr_transcludedfrom": null,
"itr_level": "1",
"itr_headinglevel": null
}
]

View file

@ -0,0 +1,26 @@
[
{
"it_id": "1",
"it_itemname": "h-X-20220720060100",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "2",
"it_itemname": "c-X-20220720060100",
"it_timestamp": "20220720060100",
"it_actor": "2"
},
{
"it_id": "3",
"it_itemname": "h-Y-20220720060200",
"it_timestamp": null,
"it_actor": null
},
{
"it_id": "4",
"it_itemname": "c-Y-20220720060200",
"it_timestamp": "20220720060200",
"it_actor": "3"
}
]

View file

@ -0,0 +1,34 @@
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.11/ http://www.mediawiki.org/xml/export-0.11.xsd" version="0.11" xml:lang="en">
<page>
<title>Talk:ThreadItemStore6</title>
<ns>1</ns>
<revision>
<contributor>
<username>X</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 06:01, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T06:01:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Y</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 06:01, 20 July 2022 (UTC)
== C ==
D. --[[User:Y]] 06:02, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T06:02:00Z</timestamp>
</revision>
<revision>
<contributor>
<username>Z</username>
</contributor>
<text xml:space="preserve">== A ==
B. --[[User:X]] 06:01, 20 July 2022 (UTC)
=== C ===
D. --[[User:Y]] 06:02, 20 July 2022 (UTC)</text>
<timestamp>2022-07-20T06:02:01Z</timestamp>
</revision>
</page>
</mediawiki>

View file

@ -0,0 +1,104 @@
<?php
namespace MediaWiki\Extension\DiscussionTools\Tests;
use ImportStringSource;
use MediaWiki\MediaWikiServices;
use TestUser;
/**
* @coversDefaultClass \MediaWiki\Extension\DiscussionTools\ThreadItemStore
*
* @group DiscussionTools
* @group Database
*/
class ThreadItemStoreTest extends IntegrationTestCase {
/**
* @inheritDoc
*/
public function getCliArg( $offset ) {
// Work around MySQL bug (T256006)
if ( $offset === 'use-normal-tables' ) {
return true;
}
return parent::getCliArg( $offset );
}
/** @var @inheritDoc */
protected $tablesUsed = [
'user',
'page',
'revision',
'discussiontools_items',
'discussiontools_item_pages',
'discussiontools_item_revisions',
'discussiontools_item_ids',
];
/**
* @dataProvider provideInsertCases
* @covers ::insertThreadItems
*/
public function testInsertThreadItems( string $dir ): void {
// Create users for the imported revisions
new TestUser( 'X' );
new TestUser( 'Y' );
new TestUser( 'Z' );
// Import revisions
$source = new ImportStringSource( static::getText( "$dir/dump.xml" ) );
$importer = MediaWikiServices::getInstance()
->getWikiImporterFactory()
->getWikiImporter( $source );
// `true` means to assign edits to the users we created above
$importer->setUsernamePrefix( 'import', true );
$importer->doImport();
// Check that expected data has been stored in the database
$expected = [];
$actual = [];
$tables = [
'discussiontools_items',
'discussiontools_item_pages',
'discussiontools_item_revisions',
'discussiontools_item_ids',
];
foreach ( $tables as $table ) {
$expected[$table] = static::getJson( "../$dir/$table.json", true );
$res = wfGetDb( DB_REPLICA )->select(
$table,
'*',
[],
__METHOD__,
[ 'ORDER BY' => 1 ]
);
foreach ( $res as $i => $row ) {
foreach ( $row as $key => $val ) {
$actual[$table][$i][$key] = $val;
}
}
}
// Optionally write updated content to the JSON files
if ( getenv( 'DISCUSSIONTOOLS_OVERWRITE_TESTS' ) ) {
foreach ( $tables as $table ) {
static::overwriteJsonFile( "../$dir/$table.json", $actual[$table] );
}
}
static::assertEquals( $expected, $actual );
}
public function provideInsertCases(): array {
return [
[ 'cases/ThreadItemStore/1simple-example' ],
[ 'cases/ThreadItemStore/2archived-section' ],
[ 'cases/ThreadItemStore/3indistinguishable-comments' ],
[ 'cases/ThreadItemStore/4transcluded-section' ],
[ 'cases/ThreadItemStore/5changed-comment-indentation' ],
[ 'cases/ThreadItemStore/6changed-heading-level' ],
];
}
}