jenkins-bot has submitted this change and it was merged. Change subject: Beta feature Flow on user talk page ......................................................................
Beta feature Flow on user talk page When $wgFlowEnableOptInBetaFeature is true, a new beta feature allows using to enable Flow on their talk page. There is also a maintenance script to auto opt-in users who are already using Flow on their user talk page. Bug: T98270 Change-Id: Ia9950c4eb1c0e5f912761a65c76c5a2b3b99c8ee --- M Flow.php M Hooks.php M autoload.php M defines.php M i18n/en.json M i18n/qqq.json A images/betafeature-flow-ltr.svg A images/betafeature-flow-rtl.svg A includes/Import/ArchiveNameHelper.php M includes/Import/Converter.php M includes/Import/Exception.php M includes/Import/LiquidThreadsApi/ConversionStrategy.php A includes/Import/OptInController.php A includes/Import/OptInUpdate.php A includes/Import/TemplateHelper.php M includes/Import/Wikitext/ConversionStrategy.php M includes/Notifications/Controller.php M includes/Notifications/Notifications.php A maintenance/FlowUpdateBetaFeaturePreference.php A tests/browser/features/opt_in.feature A tests/browser/features/step_definitions/opt_in_steps.rb M tests/browser/features/support/data_manager.rb M tests/browser/features/support/pages/abstract_flow_page.rb A tests/browser/features/support/pages/flow_component.rb D tests/browser/features/support/pages/new_wiki_page.rb A tests/browser/features/support/pages/special_notifications_page.rb A tests/browser/features/support/pages/special_preferences_page.rb A tests/browser/features/support/pages/user_talk_page.rb M tests/browser/features/support/pages/wiki_page.rb A tests/phpunit/Import/ArchiveNameHelperTest.php M tests/phpunit/Import/ConverterTest.php A tests/phpunit/Import/TemplateHelperTest.php 32 files changed, 1,337 insertions(+), 122 deletions(-) Approvals: Catrope: Looks good to me, approved jenkins-bot: Verified diff --git a/Flow.php b/Flow.php index ccc9e4b..2906511 100644 --- a/Flow.php +++ b/Flow.php @@ -166,6 +166,10 @@ $wgHooks['EchoGetDefaultNotifiedUsers'][] = 'Flow\NotificationController::getDefaultNotifiedUsers'; $wgHooks['EchoGetBundleRules'][] = 'Flow\NotificationController::onEchoGetBundleRules'; +// Beta feature Flow on user talk page +$wgHooks['GetBetaFeaturePreferences'][] = 'FlowHooks::onGetBetaFeaturePreferences'; +$wgHooks['UserSaveOptions'][] = 'FlowHooks::onUserSaveOptions'; + // Extension initialization $wgExtensionFunctions[] = 'FlowHooks::initFlowExtension'; @@ -403,3 +407,6 @@ // Temporary field to allow source wiki to be null for references until it's backfilled. $wgFlowMigrateReferenceWiki = false; + +// Enable/Disable Opt-in beta feature +$wgFlowEnableOptInBetaFeature = false; diff --git a/Hooks.php b/Hooks.php index 6bf3a96..95f53f5 100644 --- a/Hooks.php +++ b/Hooks.php @@ -6,6 +6,7 @@ use Flow\Exception\FlowException; use Flow\Exception\PermissionException; use Flow\Formatter\CheckUserQuery; +use Flow\Import\OptInUpdate; use Flow\Model\UUID; use Flow\OccupationController; use Flow\SpamFilter\AbuseFilter; @@ -230,6 +231,9 @@ require_once __DIR__.'/maintenance/FlowFixLinks.php'; $updater->addPostDatabaseUpdateMaintenance( 'FlowFixLinks' ); + + require_once __DIR__.'/maintenance/FlowUpdateBetaFeaturePreference.php'; + $updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateBetaFeaturePreference' ); return true; } @@ -1577,9 +1581,92 @@ * * @param array $namespaces Associative array mapping namespace index * to name + * @return bool */ public static function onSearchableNamespaces( &$namespaces ) { unset( $namespaces[NS_TOPIC] ); return true; } + + /** + * @return bool + */ + private static function isBetaFeatureAvailable() { + global $wgBetaFeaturesWhitelist, $wgFlowEnableOptInBetaFeature; + return $wgFlowEnableOptInBetaFeature && + ( !is_array( $wgBetaFeaturesWhitelist ) || in_array( BETA_FEATURE_FLOW_USER_TALK_PAGE, $wgBetaFeaturesWhitelist ) ); + } + + /** + * @param User $user + * @param array $prefs + * @return bool + */ + public static function onGetBetaFeaturePreferences( $user, &$prefs ) { + global $wgExtensionAssetsPath; + + if ( !self::isBetaFeatureAvailable() ) { + return true; + } + + $defaultProjectUrl = 'https://www.mediawiki.org/wiki/Extension:Flow'; + $defaultProjectTalkUrl = 'https://www.mediawiki.org/wiki/Extension_talk:Flow'; + + $prefs[BETA_FEATURE_FLOW_USER_TALK_PAGE] = array( + // The first two are message keys + 'label-message' => 'flow-talk-page-beta-feature-message', + 'desc-message' => 'flow-talk-page-beta-feature-description', + 'screenshot' => array( + 'ltr' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-ltr.svg", + 'rtl' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-rtl.svg", + ), + 'info-link' => self::getTitleUrlOrDefault( 'Project:Flow', $defaultProjectUrl ), + 'discussion-link' => self::getTitleUrlOrDefault( 'Project_talk:Flow', $defaultProjectTalkUrl ), + ); + + return true; + } + + /** + * @param string $titleText + * @param string $default + * @return string + */ + private static function getTitleUrlOrDefault( $titleText, $default ) { + $title = Title::newFromText( $titleText ); + return $title->exists() ? $title->getLocalURL() : $default; + } + + /** + * @param User $user + * @param array $options + * @return bool + */ + public static function onUserSaveOptions( $user, &$options ) { + if ( !self::isBetaFeatureAvailable() ) { + return true; + } + + if ( !array_key_exists( BETA_FEATURE_FLOW_USER_TALK_PAGE, $options ) ) { + return true; + } + + $userClone = User::newFromId( $user->getId() ); + $before = BetaFeatures::isFeatureEnabled( $userClone, BETA_FEATURE_FLOW_USER_TALK_PAGE ); + $after = $options[BETA_FEATURE_FLOW_USER_TALK_PAGE]; + $action = null; + + if ( !$before && $after ) { + $action = OptInUpdate::$ENABLE; + } elseif ( $before && !$after ) { + $action = OptInUpdate::$DISABLE; + } + + if ( $action ) { + DeferredUpdates::addUpdate( new OptInUpdate( $action, $user->getTalkPage(), $user ) ); + } + + return true; + } + } diff --git a/autoload.php b/autoload.php index 3bbd18d..5a826c6 100644 --- a/autoload.php +++ b/autoload.php @@ -162,6 +162,7 @@ 'Flow\\Formatter\\TopicListFormatter' => __DIR__ . '/includes/Formatter/TopicListFormatter.php', 'Flow\\Formatter\\TopicListQuery' => __DIR__ . '/includes/Formatter/TopicListQuery.php', 'Flow\\Formatter\\TopicRow' => __DIR__ . '/includes/Formatter/TopicRow.php', + 'Flow\\Import\\ArchiveNameHelper' => __DIR__ . '/includes/Import/ArchiveNameHelper.php', 'Flow\\Import\\Converter' => __DIR__ . '/includes/Import/Converter.php', 'Flow\\Import\\EnableFlow\\EnableFlowWikitextConversionStrategy' => __DIR__ . '/includes/Import/EnableFlow/EnableFlowWikitextConversionStrategy.php', 'Flow\\Import\\FileImportSourceStore' => __DIR__ . '/includes/Import/ImportSourceStore.php', @@ -203,6 +204,8 @@ 'Flow\\Import\\LiquidThreadsApi\\ScriptedImportRevision' => __DIR__ . '/includes/Import/LiquidThreadsApi/Objects.php', 'Flow\\Import\\LiquidThreadsApi\\TopicIterator' => __DIR__ . '/includes/Import/LiquidThreadsApi/Iterators.php', 'Flow\\Import\\NullImportSourceStore' => __DIR__ . '/includes/Import/ImportSourceStore.php', + 'Flow\\Import\\OptInController' => __DIR__ . '/includes/Import/OptInController.php', + 'Flow\\Import\\OptInUpdate' => __DIR__ . '/includes/Import/OptInUpdate.php', 'Flow\\Import\\PageImportState' => __DIR__ . '/includes/Import/Importer.php', 'Flow\\Import\\Plain\\ImportHeader' => __DIR__ . '/includes/Import/Plain/ImportHeader.php', 'Flow\\Import\\Plain\\ObjectRevision' => __DIR__ . '/includes/Import/Plain/ObjectRevision.php', @@ -213,6 +216,7 @@ 'Flow\\Import\\Postprocessor\\ProcessorGroup' => __DIR__ . '/includes/Import/Postprocessor/ProcessorGroup.php', 'Flow\\Import\\Postprocessor\\SpecialLogTopic' => __DIR__ . '/includes/Import/Postprocessor/SpecialLogTopic.php', 'Flow\\Import\\TalkpageImportOperation' => __DIR__ . '/includes/Import/Importer.php', + 'Flow\\Import\\TemplateHelper' => __DIR__ . '/includes/Import/TemplateHelper.php', 'Flow\\Import\\TopicImportState' => __DIR__ . '/includes/Import/Importer.php', 'Flow\\Import\\Wikitext\\ConversionStrategy' => __DIR__ . '/includes/Import/Wikitext/ConversionStrategy.php', 'Flow\\Import\\Wikitext\\ImportSource' => __DIR__ . '/includes/Import/Wikitext/ImportSource.php', @@ -325,11 +329,13 @@ 'Flow\\Tests\\Formatter\\RevisionFormatterTest' => __DIR__ . '/tests/phpunit/Formatter/RevisionFormatterTest.php', 'Flow\\Tests\\Handlebars\\FlowPostMetaActionsTest' => __DIR__ . '/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php', 'Flow\\Tests\\HookTest' => __DIR__ . '/tests/phpunit/HookTest.php', + 'Flow\\Tests\\Import\\ArchiveNameHelperTest' => __DIR__ . '/tests/phpunit/Import/ArchiveNameHelperTest.php', 'Flow\\Tests\\Import\\ConverterTest' => __DIR__ . '/tests/phpunit/Import/ConverterTest.php', 'Flow\\Tests\\Import\\HistoricalUIDGeneratorTest' => __DIR__ . '/tests/phpunit/Import/HistoricalUIDGeneratorTest.php', 'Flow\\Tests\\Import\\LiquidThreadsApi\\ConversionStrategyTest' => __DIR__ . '/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php', 'Flow\\Tests\\Import\\PageImportStateTest' => __DIR__ . '/tests/phpunit/Import/PageImportStateTest.php', 'Flow\\Tests\\Import\\TalkpageImportOperationTest' => __DIR__ . '/tests/phpunit/Import/TalkpageImportOperationTest.php', + 'Flow\\Tests\\Import\\TemplateHelperTest' => __DIR__ . '/tests/phpunit/Import/TemplateHelperTest.php', 'Flow\\Tests\\Import\\Wikitext\\ConversionStrategyTest' => __DIR__ . '/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php', 'Flow\\Tests\\Import\\Wikitext\\ImportSourceTest' => __DIR__ . '/tests/phpunit/Import/Wikitext/ImportSourceTest.php', 'Flow\\Tests\\LinksTableTest' => __DIR__ . '/tests/phpunit/LinksTableTest.php', diff --git a/defines.php b/defines.php index 40381ec..72b0196 100644 --- a/defines.php +++ b/defines.php @@ -3,4 +3,5 @@ // Constants define( 'RC_FLOW', 142 ); // Random number chosen. Can be replaced with rc_source; see bug 72157. define( 'NS_TOPIC', 2600 ); -define( 'FLOW_TALK_PAGE_MANAGER_USER', 'Flow talk page manager' ); \ No newline at end of file +define( 'FLOW_TALK_PAGE_MANAGER_USER', 'Flow talk page manager' ); +define( 'BETA_FEATURE_FLOW_USER_TALK_PAGE', 'beta-feature-flow-user-talk-page' ); diff --git a/i18n/en.json b/i18n/en.json index 5af2b4b..14ed8b9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -398,6 +398,7 @@ "flow-special-enableflow-page-is-liquidthreads": "There is a LiquidThreads page at [[:$1]].", "flow-special-enableflow-confirmation": "You have successfully created a Flow board at [[$1]].", "flow-conversion-archive-page-name-format": "%s/Archive %d\n%s/Archive%d\n%s/archive %d\n%s/archive%d", + "flow-conversion-archive-flow-page-name-format": "%s/Flow Archive %d\n%s/FlowArchive%d", "flow-spam-confirmedit-form": "Please confirm you are a human by solving the below captcha: $1", "flow-embedding-unsupported": "Discussions cannot be embedded yet.", "mw-ui-unsubmitted-confirm": "You have unsubmitted changes on this page. Are you sure you want to navigate away and lose your work?", @@ -557,5 +558,14 @@ "flow-mark-revision-patrolled-link-title": "Mark this page as patrolled", "flow-mark-diff-patrolled-link-text": "Mark as patrolled", "flow-mark-diff-patrolled-link-title": "Mark as patrolled", - "flow-description-last-modified-at": "This description was last modified on $1, at $2." + "flow-description-last-modified-at": "This description was last modified on $1, at $2.", + "flow-talk-page-beta-feature-message": "Flow on user talk", + "flow-talk-page-beta-feature-description": "Enables a new structured discussion system on your user talk page. Flow simplifies talk page discussions with clear places to write and reply, and allows conversation-level notifications. Existing wikitext discussions are moved to an archive. This feature is not auto-enabled; users will have to enable it separately. Disabling this feature will move the Flow board to a subpage and un-archive the previous talkpage.", + "flow-notification-link-text-enabled-on-talkpage": "View user talk page", + "flow-notification-enabled-on-talkpage-title-message": "New discussion system enabled for your user talk page<br/><small>Available at [[$1]]</small>", + "flow-notification-enabled-on-talkpage-email-subject": "New discussion system on $1", + "flow-notification-enabled-on-talkpage-email-batch-body": "Flow, the new wiki discussion system, has been enabled on your user talk page on {{SITENAME}}. You can get more information, provide feedback or disable the new system any time from the Beta features section in your preferences.", + "flow-beta-feature-add-archive-template-edit-summary": "Adding archive template", + "flow-beta-feature-remove-archive-template-edit-summary": "Removing archive template" + } diff --git a/i18n/qqq.json b/i18n/qqq.json index 6bcd486..e3def7f 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -405,6 +405,7 @@ "flow-special-enableflow-page-is-liquidthreads": "Error given on Special:EnableFlow if the page to enable Flow on is a LiquidThreads page. Parameters:\n$1 - Page name where user requested to put Flow board", "flow-special-enableflow-confirmation": "Confirmation message on Special:EnableFlow saying that you have successfully created a board Parameters:\n$1 - Page name of new Flow board", "flow-conversion-archive-page-name-format": "Archive format used when enabling Flow on existing pages. This is a format string. %s and %d should be present. %s represents the title of the page where Flow is being enabled. %d represents a number that will be incremented if an archive page with the same name already exist. Multiple formats can be specified separated by the new line character (\\n).", + "flow-conversion-archive-flow-page-name-format": "Archive format used when archiving a Flow page. This is a format string. %s and %d should be present. %s represents the title of the Flow page being archived. %d represents a number that will be incremented if an archive page with the same name already exist. Multiple formats can be specified separated by the new line character (\\n).", "flow-spam-confirmedit-form": "Error message when ConfirmEdit flagged the submitted content (because an anonymous user submitted external links, possibly spam). A captcha will be displayed after this error message. Parameters:\n* $1 - the HTML for the captcha form.", "flow-embedding-unsupported": "Error message displayed if a user tries to transclude a Flow page.", "mw-ui-unsubmitted-confirm": "You have unsubmitted changes on this page. Are you sure you want to navigate away and lose your work?", @@ -564,5 +565,13 @@ "flow-mark-revision-patrolled-link-title": "Title of the link to mark a revision as patrolled on a revision page.", "flow-mark-diff-patrolled-link-text": "Text of the link to mark a revision as patrolled on a diff page.\n{{Identical|Mark as patrolled}}", "flow-mark-diff-patrolled-link-title": "Title of the link to mark a revision as patrolled on a diff page.\n{{Identical|Mark as patrolled}}", - "flow-description-last-modified-at": "Timestamp line stating when description was modified. See also {{msg-mw|lastmodifiedat}}. Parameters:\n* $1 - Date\n* $2 - Time" + "flow-description-last-modified-at": "Timestamp line stating when description was modified. See also {{msg-mw|lastmodifiedat}}. Parameters:\n* $1 - Date\n* $2 - Time", + "flow-talk-page-beta-feature-message": "Title of the beta feature to enable Flow on the user's talk page.", + "flow-talk-page-beta-feature-description": "Description of the beta feature to enable Flow on the user's talk page.", + "flow-notification-link-text-enabled-on-talkpage": "Primary link text in email for notification when Flow was enabled on your talk page.", + "flow-notification-enabled-on-talkpage-title-message": "Text of the web notification when Flow was enabled on your talk page.\n Parameters:\n* $1 - Title where Flow was enabled.", + "flow-notification-enabled-on-talkpage-email-subject": "Email subject for notification when Flow was enabled on your talk page.\n Parameters:\n* $1 - Title where Flow was enabled.", + "flow-notification-enabled-on-talkpage-email-batch-body": "Email body for notification when Flow was enabled on your talk page.\n Parameters:\n* $1 - Title where Flow was enabled.", + "flow-beta-feature-add-archive-template-edit-summary": "Edit summary message for the revision adding the archive template to an archived talk page.", + "flow-beta-feature-remove-archive-template-edit-summary": "Edit summary message for the revision removing the archive template to an archived talk page." } diff --git a/images/betafeature-flow-ltr.svg b/images/betafeature-flow-ltr.svg new file mode 100644 index 0000000..9fb14a6 --- /dev/null +++ b/images/betafeature-flow-ltr.svg @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" width="264" height="162" viewBox="0 0 264 162" id="svg2"> + <g id="g4"> + <path d="M13.366 161.357L.5 151.75V.5h263v151.75l-9 6.704V42.5h-39v112.6l-4.393-3.73-7.607 5.33V42.5h-155v110.85l-9.75 8.03-13.917-10.02z" id="path6" fill="#fff"/> + <path d="M263 1v150.998l-8 5.96V42h-40v112.02l-3.274-2.78-.59-.5-.632.442-6.504 4.556V42H48v111.1l-9.257 7.66-13.295-9.57-.645-.463-.598.52-10.864 9.47L1 151.5V1h262m1-1H0v152l13.39 10 11.475-10 13.89 10L49 153.6V43h154v114.66l8.078-5.66 4.922 4.18V43h38v116.95l10-7.45V0z" id="path8" fill="#e5e5e5"/> + </g> + <path d="M203 157.66V43H49v110.6l2.145-1.6L63.7 162l13.81-10 14.228 10 12.972-10 12.973 10 13.81-10 12.137 10 13.39-10 14.23 10 12.972-10 12.974 10 5.804-4.34z" id="path10" fill="#e5e5e5"/> + <path d="M11 36c0-7.732 6.268-14 14-14s14 6.268 14 14-6.268 14-14 14-14-6.268-14-14z" id="path12" fill="#e5e5e5"/> + <path d="M254 159.95V43h-38v113.18l7.55 5.82 13.812-10 13.89 10 2.748-2.05z" id="path14" fill="#e5e5e5"/> + <path d="M38 132.354V72H13v60.354h25z" id="path16" fill="#e5e5e5"/> + <path d="M232.51 5h26v6h-26V5z" id="path18" fill="#e5e5e5"/> + <path d="M208.51 5h22v6h-22z" id="path20" fill="#e5e5e5"/> + <path d="M142 6v4H50V6h92m1-1H49v6h94V5z" id="path22" fill="#e5e5e5"/> + <path d="M184.51 5h22v6h-22z" id="path24" fill="#e5e5e5"/> + <path d="M161.51 5h13v6h-13z" id="path26" fill="#e5e5e5"/> + <path d="M176.51 5h6v6h-6z" id="path28" fill="#e5e5e5"/> + <path d="M153.51 5h6v6h-6z" id="path30" fill="#e5e5e5"/> + <path d="M9 5h32v6H9z" id="path32" fill="#e5e5e5"/> + <path d="M2 14.5h260" id="path34" fill="#e5e5e5" stroke="#e5e5e5"/> + <path d="M52 7h2v2h-2z" id="path36" fill="#e5e5e5"/> + <path d="M38 59v-5H13v5h25z" id="path38" fill="#e5e5e5"/> + <path d="M163.047 85.46v39.226l8.717 8.717h-61.02V85.46h52.303zM88.953 63.668h52.302v17.434h-34.868v30.51h-26.15l8.716-8.718V63.668z" id="path4059" fill="#347bff"/> +</svg> diff --git a/images/betafeature-flow-rtl.svg b/images/betafeature-flow-rtl.svg new file mode 100644 index 0000000..a0de77b --- /dev/null +++ b/images/betafeature-flow-rtl.svg @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" width="264" height="162" viewBox="0 0 264 162" id="svg2"> + <g id="g4"> + <path d="M250.634 161.357l12.866-9.608V.5H.5v151.75l9 6.704V42.5h39v112.6l4.393-3.73 7.607 5.33V42.5h155v110.85l9.75 8.03 13.917-10.02z" id="path6" fill="#fff"/> + <path d="M1 1v150.998l8 5.96V42h40v112.02l3.274-2.78.59-.5.632.442L60 155.738V42h156v111.1l9.257 7.66 13.295-9.57.645-.463.598.52 10.864 9.47L263 151.5V1H1M0 0h264v152l-13.39 10-11.475-10-13.89 10L215 153.6V43H61v114.66L52.922 152 48 156.18V43H10v116.95L0 152.5V0z" id="path8" fill="#e5e5e5"/> + </g> + <path d="M61 157.66V43h154v110.6l-2.145-1.6-12.555 10-13.81-10-14.228 10-12.972-10-12.973 10-13.81-10-12.137 10-13.39-10-14.23 10-12.972-10-12.974 10L61 157.66z" id="path10" fill="#e5e5e5"/> + <path d="M253 36c0-7.732-6.268-14-14-14s-14 6.268-14 14 6.268 14 14 14 14-6.268 14-14z" id="path12" fill="#e5e5e5"/> + <path d="M10 159.95V43h38v113.18L40.45 162l-13.812-10-13.89 10L10 159.95z" id="path14" fill="#e5e5e5"/> + <path d="M226 132.354V72h25v60.354h-25z" id="path16" fill="#e5e5e5"/> + <path d="M31.49 5h-26v6h26V5z" id="path18" fill="#e5e5e5"/> + <path d="M55.49 5h-22v6h22z" id="path20" fill="#e5e5e5"/> + <path d="M122 6v4h92V6h-92m-1-1h94v6h-94V5z" id="path22" fill="#e5e5e5"/> + <path d="M79.49 5h-22v6h22z" id="path24" fill="#e5e5e5"/> + <path d="M102.49 5h-13v6h13z" id="path26" fill="#e5e5e5"/> + <path d="M87.49 5h-6v6h6z" id="path28" fill="#e5e5e5"/> + <path d="M110.49 5h-6v6h6z" id="path30" fill="#e5e5e5"/> + <path d="M255 5h-32v6h32z" id="path32" fill="#e5e5e5"/> + <path d="M262 14.5H2" id="path34" fill="#e5e5e5" stroke="#e5e5e5"/> + <path d="M212 7h-2v2h2z" id="path36" fill="#e5e5e5"/> + <path d="M226 59v-5h25v5h-25z" id="path38" fill="#e5e5e5"/> + <path d="M100.953 85.46v39.226l-8.717 8.717h61.02V85.46h-52.303zm74.094-21.792h-52.302v17.434h34.868v30.51h26.15l-8.716-8.718V63.668z" id="path4059" fill="#347bff"/> +</svg> diff --git a/includes/Import/ArchiveNameHelper.php b/includes/Import/ArchiveNameHelper.php new file mode 100644 index 0000000..c0a3088 --- /dev/null +++ b/includes/Import/ArchiveNameHelper.php @@ -0,0 +1,95 @@ +<?php + +namespace Flow\Import; + + +use Flow\Repository\TitleRepository; +use Title; + +class ArchiveNameHelper { + + /** + * Helper method decides on an archive title based on a set of printf formats. + * Each format should first have a %s for the base page name and a %d for the + * archive page number. Example: + * + * %s/Archive %d + * + * It will iterate through the formats looking for an existing format. If no + * formats are currently in use the first format will be returned with n=1. + * If a format is currently in used we will look for the first unused page + * >= to n=1 and <= to n=20. + * + * @param Title $source + * @param string[] $formats + * @param TitleRepository|null $titleRepo + * @return Title + * @throws ImportException + */ + public function decideArchiveTitle( Title $source, array $formats, TitleRepository $titleRepo = null ) { + $info = self::findLatestArchiveInfo( $source, $formats, $titleRepo ); + $format = $info ? $info['format'] : $formats[0]; + $counter = $info ? $info['counter'] + 1 : 1; + $text = $source->getPrefixedText(); + return Title::newFromText( sprintf( $format, $text, $counter ) ); + } + + /** + * @param Title $source + * @param array $formats + * @param TitleRepository $titleRepo + * @return bool|mixed + */ + public function findLatestArchiveTitle( Title $source, array $formats, TitleRepository $titleRepo = null ) { + $info = self::findLatestArchiveInfo( $source, $formats, $titleRepo ); + return $info ? $info['title'] : false; + } + + /** + * @param Title $source + * @param array $formats + * @param TitleRepository $titleRepo + * @return bool|mixed + */ + protected function findLatestArchiveInfo( Title $source, array $formats, TitleRepository $titleRepo = null ) { + if ( $titleRepo === null ) { + $titleRepo = new TitleRepository(); + } + + $format = false; + $n = 1; + $text = $source->getPrefixedText(); + foreach ( $formats as $potential ) { + $title = Title::newFromText( sprintf( $potential, $text, $n ) ); + if ( $title && $titleRepo->exists( $title ) ) { + $format = $potential; + break; + } + } + if ( $format === false ) { + // no archive page matches any format + return false; + } + + $archivePages = array(); + for ( $n = 1; $n <= 20; ++$n ) { + $title = Title::newFromText( sprintf( $format, $text, $n ) ); + if ( $title && $titleRepo->exists( $title ) ) { + $archivePages[] = array( + 'title' => $title, + 'format' => $format, + 'counter' => $n + ); + } else { + break; + } + } + + if ( $archivePages ) { + return end( $archivePages ); + } + + return false; + } + +} \ No newline at end of file diff --git a/includes/Import/Converter.php b/includes/Import/Converter.php index a925a0f..44c8e84 100644 --- a/includes/Import/Converter.php +++ b/includes/Import/Converter.php @@ -306,52 +306,4 @@ throw new ImportException( "Failed creating archive cleanup revision at {$archiveTitle}" ); } } - - /** - * Helper method decides on an archive title based on a set of printf formats. - * Each format should first have a %s for the base page name and a %d for the - * archive page number. Example: - * - * %s/Archive %d - * - * It will iterate through the formats looking for an existing format. If no - * formats are currently in use the first format will be returned with n=1. - * If a format is currently in used we will look for the first unused page - * >= to n=1 and <= to n=20. - * - * @param Title $source - * @param string[] $formats - * @param TitleRepository|null $titleRepo - * @return Title - * @throws ImportException - */ - static public function decideArchiveTitle( Title $source, array $formats, TitleRepository $titleRepo = null ) { - if ( $titleRepo === null ) { - $titleRepo = new TitleRepository(); - } - - $format = false; - $n = 1; - $text = $source->getPrefixedText(); - foreach ( $formats as $potential ) { - $title = Title::newFromText( sprintf( $potential, $text, $n ) ); - if ( $title && $titleRepo->exists( $title ) ) { - $format = $potential; - break; - } - } - if ( $format === false ) { - // assumes this creates a valid title - return Title::newFromText( sprintf( $formats[0], $text, $n ) ); - } - - for ( $n = 2; $n <= 20; ++$n ) { - $title = Title::newFromText( sprintf( $format, $text, $n ) ); - if ( $title && !$titleRepo->exists( $title ) ) { - return $title; - } - } - - throw new ImportException( "All titles 1 through 20 (inclusive) exist for format: $format" ); - } } diff --git a/includes/Import/Exception.php b/includes/Import/Exception.php index e3b5c4c..00da767 100644 --- a/includes/Import/Exception.php +++ b/includes/Import/Exception.php @@ -1,11 +1,12 @@ <?php namespace Flow\Import; +use Flow\Exception\FlowException; /** * Base class for errors in the Flow\Import module */ -class ImportException extends \Flow\Exception\FlowException { +class ImportException extends FlowException { } /** diff --git a/includes/Import/LiquidThreadsApi/ConversionStrategy.php b/includes/Import/LiquidThreadsApi/ConversionStrategy.php index fb06563..6767b43 100644 --- a/includes/Import/LiquidThreadsApi/ConversionStrategy.php +++ b/includes/Import/LiquidThreadsApi/ConversionStrategy.php @@ -3,6 +3,7 @@ namespace Flow\Import\LiquidThreadsApi; use DatabaseBase; +use Flow\Import\ArchiveNameHelper; use Flow\Import\Converter; use Flow\Import\IConversionStrategy; use Flow\Import\ImportSourceStore; @@ -111,7 +112,8 @@ * @return Title */ public function decideArchiveTitle( Title $source ) { - return Converter::decideArchiveTitle( $source, array( + $archiveNameHelper = new ArchiveNameHelper(); + return $archiveNameHelper->decideArchiveTitle( $source, array( '%s/LQT Archive %d', ) ); } diff --git a/includes/Import/OptInController.php b/includes/Import/OptInController.php new file mode 100644 index 0000000..e33de92 --- /dev/null +++ b/includes/Import/OptInController.php @@ -0,0 +1,450 @@ +<?php + +namespace Flow\Import; + +use DateTime; +use DateTimeZone; +use DerivativeContext; +use Flow\Collection\HeaderCollection; +use Flow\NotificationController; +use Flow\OccupationController; +use Flow\Parsoid\Utils; +use Flow\RevisionActionPermissions; +use Flow\WorkflowLoaderFactory; +use IContextSource; +use MovePage; +use RequestContext; +use Revision; +use Title; +use Flow\Container; +use User; +use WikiPage; +use WikitextContent; + + +/** + * Entry point for enabling Flow on a page. + */ +class OptInController { + + /** + * @var OccupationController + */ + private $occupationController; + + /** + * @var NotificationController + */ + private $notificationController; + + /** + * @var ArchiveNameHelper + */ + private $archiveNameHelper; + + /** + * @var IContextSource + */ + private $context; + + /** + * @var User + */ + private $user; + + public function __construct() { + $this->occupationController = Container::get( 'occupation_controller' ); + $this->notificationController = Container::get( 'controller.notification' ); + $this->archiveNameHelper = new ArchiveNameHelper(); + $this->user = $this->occupationController->getTalkpageManager(); + $this->context = new DerivativeContext( RequestContext::getMain() ); + $this->context->setUser( $this->user ); + + // We need to replace the 'permissions' object in the container + // so it is initialized with the user we are trying to + // impersonate (Talk page manager user). + $user = $this->user; + Container::getContainer()->extend( 'permissions', function ( $p, $c ) use ( $user ) { + return new RevisionActionPermissions( $c['flow_actions'], $user ); + } ); + } + + /** + * @param Title $title + * @param User $user + */ + public function enable( Title $title, User $user ) { + if ( $this->isFlowBoard( $title ) ) { + // already a Flow board + return; + } + + // archive existing wikitext talk page + $linkToArchivedTalkpage = null; + if ( $title->exists( Title::GAID_FOR_UPDATE ) ) { + $wikitextTalkpageArchiveTitle = $this->archiveExistingTalkpage( $title ); + $this->addArchiveTemplate( $wikitextTalkpageArchiveTitle, $title ); + $linkToArchivedTalkpage = $this->buildLinkToArchivedTalkpage( $wikitextTalkpageArchiveTitle ); + } + + // create or restore flow board + $archivedFlowPage = $this->findLatestFlowArchive( $title ); + if ( $archivedFlowPage ) { + $this->restoreExistingFlowBoard( $archivedFlowPage, $title, $linkToArchivedTalkpage ); + } else { + $this->createFlowBoard( $title, $linkToArchivedTalkpage ); + $this->notificationController->notifyFlowEnabledOnTalkpage( $user ); + } + } + + /** + * @param Title $title + */ + public function disable( Title $title ) { + if ( !$this->isFlowBoard( $title ) ) { + return; + } + + // archive the flow board + $flowArchiveTitle = $this->findNextFlowArchive( $title ); + $this->movePage( $title, $flowArchiveTitle ); + $this->removeArchivedTalkpageTemplateFromFlowBoardDescription( $flowArchiveTitle ); + + // restore the original wikitext talk page + $archivedTalkpage = $this->findLatestArchive( $title ); + if ( $archivedTalkpage ) { + $this->movePage( $archivedTalkpage, $title ); + $this->removeArchiveTemplateFromWikitextTalkpage( $title ); + } + } + + /** + * @param Title $title + * @return bool + */ + private function isFlowBoard( Title $title ) { + return $title->getContentModel( Title::GAID_FOR_UPDATE ) === CONTENT_MODEL_FLOW_BOARD; + } + + /** + * @param Title $from + * @param Title $to + */ + private function movePage( Title $from, Title $to ) { + $mp = new MovePage( $from, $to ); + $mp->move( $this->user, null, false ); + } + + /** + * @param $msgKey + * @param array $args + * @throws ImportException + */ + private function fatal( $msgKey, $args = array() ) { + throw new ImportException( wfMessage( $msgKey, $args )->inContentLanguage()->text() ); + } + + /** + * @param string $str + * @return array + */ + private function fromNewlineSeparated( $str ) { + return explode( "\n", $str ); + } + + /** + * @param Title $title + * @return Title|false + */ + private function findLatestArchive( Title $title ) { + $archiveFormats = $this->fromNewlineSeparated( + wfMessage( 'flow-conversion-archive-page-name-format' )->inContentLanguage()->plain() ); + return $this->archiveNameHelper->findLatestArchiveTitle( $title, $archiveFormats ); + } + + /** + * @param Title $title + * @return Title + * @throws ImportException + */ + private function findNextArchive( Title $title ) { + $archiveFormats = $this->fromNewlineSeparated( + wfMessage( 'flow-conversion-archive-page-name-format' )->inContentLanguage()->plain() ); + return $this->archiveNameHelper->decideArchiveTitle( $title, $archiveFormats ); + } + + /** + * @param Title $title + * @return Title|false + */ + private function findLatestFlowArchive( Title $title ) { + $archiveFormats = $this->fromNewlineSeparated( + wfMessage( 'flow-conversion-archive-flow-page-name-format' )->inContentLanguage()->plain() ); + return $this->archiveNameHelper->findLatestArchiveTitle( $title, $archiveFormats ); + } + + /** + * @param Title $title + * @return Title + * @throws ImportException + */ + private function findNextFlowArchive( Title $title ) { + $archiveFormats = $this->fromNewlineSeparated( + wfMessage( 'flow-conversion-archive-flow-page-name-format' )->inContentLanguage()->plain() ); + return $this->archiveNameHelper->decideArchiveTitle( $title, $archiveFormats ); + } + + /** + * @param Title $title + * @param string $contentText + * @param string $summary + * @throws ImportException + * @throws \MWException + */ + private function createRevision( Title $title, $contentText, $summary ) { + $page = WikiPage::factory( $title ); + $newContent = new WikitextContent( $contentText ); + $status = $page->doEditContent( + $newContent, + $summary, + EDIT_FORCE_BOT | EDIT_SUPPRESS_RC, + false, + $this->user + ); + + if ( !$status->isGood() ) { + throw new ImportException( "Failed creating revision at {$title}" ); + } + } + + /** + * @param Title $title + * @param $boardDescription + * @throws ImportException + * @throws \Flow\Exception\CrossWikiException + * @throws \Flow\Exception\InvalidInputException + */ + private function createFlowBoard( Title $title, $boardDescription ) { + /** @var WorkflowLoaderFactory $loaderFactory */ + $loaderFactory = Container::get( 'factory.loader.workflow' ); + $page = $title->getPrefixedText(); + + $allowCreationStatus = $this->occupationController->allowCreation( $title, $this->user, false ); + if ( !$allowCreationStatus->isGood() ) { + $this->fatal( 'flow-special-enableflow-board-creation-not-allowed', $page ); + } + + $loader = $loaderFactory->createWorkflowLoader( $title ); + $blocks = $loader->getBlocks(); + + if ( !$boardDescription ) { + $boardDescription = ' '; + } + + $action = 'edit-header'; + $params = array( + 'header' => array( + 'content' => $boardDescription, + 'format' => 'wikitext', + ), + ); + + $blocksToCommit = $loader->handleSubmit( + $this->context, + $action, + $params + ); + + foreach ( $blocks as $block ) { + if ( $block->hasErrors() ) { + $errors = $block->getErrors(); + + foreach ( $errors as $errorKey ) { + $this->fatal( $block->getErrorMessage( $errorKey ) ); + } + } + } + + $loader->commit( $blocksToCommit ); + } + + /** + * @param Title $title + * @return Title + */ + private function archiveExistingTalkpage( Title $title ) { + $archiveTitle = $this->findNextArchive( $title ); + $this->movePage( $title, $archiveTitle ); + return $archiveTitle; + } + + /** + * @param Title $archivedFlowPage + * @param Title $title + * @param string|null $addToHeader + */ + private function restoreExistingFlowBoard( Title $archivedFlowPage, Title $title, $addToHeader = null ) { + $this->movePage( $archivedFlowPage, $title ); + if ( $addToHeader ) { + $this->editBoardDescription( $title, function( $oldDesc ) use ( $addToHeader ) { + return $oldDesc . "\n\n" . $addToHeader; + }, 'wikitext' ); + } + } + + /** + * @param Title $title + * @return string + * @throws \MWException + */ + private function getContent( Title $title ) { + $page = WikiPage::factory( $title ); + $page->loadPageData( 'fromdbmaster' ); + $revision = $page->getRevision(); + if ( $revision ) { + $content = $revision->getContent( Revision::FOR_PUBLIC ); + if ( $content instanceof WikitextContent ) { + return $content->getNativeData(); + } + } + + return ''; + } + + /** + * @param Title $archiveTitle + * @return string + */ + private function buildLinkToArchivedTalkpage( Title $archiveTitle ) { + $now = new DateTime( "now", new DateTimeZone( "GMT" ) ); + $arguments = array( + 'archive' => $archiveTitle->getPrefixedText(), + 'date' => $now->format( 'Y-m-d' ), + ); + $template = wfMessage( 'flow-importer-wt-converted-template' )->inContentLanguage()->plain(); + return $this->formatTemplate( $template, $arguments ); + } + + /** + * @param string $name + * @param array $args + * @return string + */ + private function formatTemplate( $name, $args ) { + $arguments = implode( '|', + array_map( + function( $key, $value ) { + return "$key=$value"; + }, + array_keys( $args ), + array_values( $args ) ) + ); + return "{{{$name}|$arguments}}"; + } + + /** + * @param Title $flowArchiveTitle + */ + private function removeArchivedTalkpageTemplateFromFlowBoardDescription( Title $flowArchiveTitle ) { + $this->editBoardDescription( $flowArchiveTitle, function( $oldDesc ) { + $templateName = wfMessage( 'flow-importer-wt-converted-template' )->inContentLanguage()->plain(); + return TemplateHelper::removeFromHtml( $oldDesc, $templateName ); + }, 'html' ); + } + + /** + * @param Title $title + * @param callable $newDescriptionCallback + * @param string $format + * @throws ImportException + * @throws \Flow\Exception\InvalidDataException + */ + private function editBoardDescription( Title $title, callable $newDescriptionCallback, $format = 'html' ) { + /** @var WorkflowLoaderFactory $loader */ + $factory = Container::get( 'factory.loader.workflow' ); + + /** @var WorkflowLoader $loader */ + $loader = $factory->createWorkflowLoader( $title ); + + $collection = HeaderCollection::newFromId( $loader->getWorkflow()->getId() ); + $revision = $collection->getLastRevision(); + $content = $revision->getContent(); + + if ( $format === 'wikitext' ) { + $content = Utils::convert( 'html', 'wikitext', $content, $title ); + } + $newDescription = call_user_func( $newDescriptionCallback, $content ); + + $action = 'edit-header'; + $params = array( + 'header' => array( + 'content' => $newDescription, + 'format' => $format, + 'prev_revision' => $revision->getRevisionId()->getAlphadecimal() + ), + ); + + $blocks = $loader->getBlocks(); + + $blocksToCommit = $loader->handleSubmit( + $this->context, + $action, + $params + ); + + foreach ( $blocks as $block ) { + if ( $block->hasErrors() ) { + $errors = $block->getErrors(); + + foreach ( $errors as $errorKey ) { + $this->fatal( $block->getErrorMessage( $errorKey ) ); + } + } + } + + $loader->commit( $blocksToCommit ); + } + + /** + * @param Title $archive + * @param Title $current + * @throws ImportException + */ + private function addArchiveTemplate( Title $archive, Title $current ) { + $templateName = wfMessage( 'flow-importer-wt-converted-archive-template' )->inContentLanguage()->plain(); + $now = new DateTime( "now", new DateTimeZone( "GMT" ) ); + $template = $this->formatTemplate( $templateName, array( + 'from' => $current->getPrefixedText(), + 'date' => $now->format( 'Y-m-d' ), + ) ); + + $content = $this->getContent( $archive ); + + $this->createRevision( + $archive, + $template . "\n\n" . $content, + wfMessage( 'flow-beta-feature-add-archive-template-edit-summary' )->inContentLanguage()->plain()); + } + + /** + * @param Title $title + * @throws ImportException + */ + private function removeArchiveTemplateFromWikitextTalkpage( Title $title ) { + $content = $this->getContent( $title ); + if ( !$content ) { + return; + } + + $content = Utils::convert( 'wikitext', 'html', $content, $title ); + $templateName = wfMessage( 'flow-importer-wt-converted-archive-template' )->inContentLanguage()->plain(); + + $newContent = TemplateHelper::removeFromHtml( $content, $templateName ); + + $this->createRevision( + $title, + Utils::convert( 'html', 'wikitext', $newContent, $title ), + wfMessage( 'flow-beta-feature-remove-archive-template-edit-summary' )->inContentLanguage()->plain()); + } + +} \ No newline at end of file diff --git a/includes/Import/OptInUpdate.php b/includes/Import/OptInUpdate.php new file mode 100644 index 0000000..c5e6f27 --- /dev/null +++ b/includes/Import/OptInUpdate.php @@ -0,0 +1,59 @@ +<?php + +namespace Flow\Import; + +use DeferrableUpdate; +use MWExceptionHandler; +use Title; +use User; + +class OptInUpdate implements DeferrableUpdate { + + public static $ENABLE = 'enable'; + public static $DISABLE = 'disable'; + + /** + * @var string + */ + protected $action; + + /** + * @var Title + */ + protected $talkpage; + + /** + * @var User + */ + protected $user; + + /** + * @param string $action + * @param Title $talkpage + * @param User $user + */ + public function __construct( $action, Title $talkpage, User $user ) { + $this->action = $action; + $this->talkpage = $talkpage; + $this->user = $user; + } + + /** + * Enable or disable Flow on a talk page + */ + function doUpdate() { + $c = new OptInController(); + try { + if ( $this->action === self::$ENABLE ) { + $c->enable( $this->talkpage, $this->user ); + } elseif ( $this->action === self::$DISABLE ) { + $c->disable( $this->talkpage ); + } else { + wfLogWarning( 'OptInUpdate: unrecognized action: ' . $this->action ); + } + } catch ( \Exception $e ) { + MWExceptionHandler::logException( $e ); + } + } +} + diff --git a/includes/Import/TemplateHelper.php b/includes/Import/TemplateHelper.php new file mode 100644 index 0000000..67ccac3 --- /dev/null +++ b/includes/Import/TemplateHelper.php @@ -0,0 +1,66 @@ +<?php + +namespace Flow\Import; + +use DOMDocument; +use DOMElement; +use DOMXPath; +use Flow\Parsoid\Utils; + +class TemplateHelper { + + /** + * @param string $htmlContent + * @param string $templateName + * @return string + * @throws \Flow\Exception\WikitextException + */ + public static function removeFromHtml( $htmlContent, $templateName ) { + $dom = Utils::createDOM( $htmlContent ); + $xpath = new DOMXPath( $dom ); + $templateNodes = $xpath->query( '//*[@typeof="mw:Transclusion"]' ); + + foreach ( $templateNodes as $templateNode ) { + /** @var DOMElement $templateNode */ + if ( $templateNode->hasAttribute( 'data-mw' ) ) { + $name = self::getTemplateName( $templateNode->getAttribute( 'data-mw' ) ); + if ( $name === $templateName ) { + $templateNode->parentNode->removeChild( $templateNode ); + if ( $templateNode->hasAttribute( 'about' ) ) { + $about = $templateNode->getAttribute( 'about' ); + self::removeAboutNodes( $dom, $about ); + } + } + } + } + + $body = $xpath->query( '/html/body' )->item(0); + return $dom->saveHTML( $body ); + } + + /** + * @param string $dataMW + * @return string|null + */ + private static function getTemplateName( $dataMW ) { + try { + $mwAttr = json_decode( $dataMW ); + return $mwAttr->parts[0]->template->target->wt; + } catch ( \Exception $e ) { + return null; + } + } + + /** + * @param DOMDocument $dom + * @param string $about + */ + private static function removeAboutNodes( DOMDocument $dom, $about ) { + $xpath = new DOMXPath( $dom ); + $aboutNodes = $xpath->query( '//*[@about="' . $about . '"]' ); + foreach ( $aboutNodes as $aboutNode ) { + $aboutNode->parentNode->removeChild( $aboutNode ); + } + } + +} \ No newline at end of file diff --git a/includes/Import/Wikitext/ConversionStrategy.php b/includes/Import/Wikitext/ConversionStrategy.php index fe0d1f9..6fd8c30 100644 --- a/includes/Import/Wikitext/ConversionStrategy.php +++ b/includes/Import/Wikitext/ConversionStrategy.php @@ -4,6 +4,7 @@ use DateTime; use DateTimeZone; +use Flow\Import\ArchiveNameHelper; use Flow\Import\Converter; use Flow\Import\IConversionStrategy; use Flow\Import\ImportSourceStore; @@ -130,7 +131,8 @@ * {@inheritDoc} */ public function decideArchiveTitle( Title $source ) { - return Converter::decideArchiveTitle( $source, $this->archiveTitleSuggestions ); + $archiveNameHelper = new ArchiveNameHelper(); + return $archiveNameHelper->decideArchiveTitle( $source, $this->archiveTitleSuggestions ); } /** diff --git a/includes/Notifications/Controller.php b/includes/Notifications/Controller.php index 136ed67..a610318 100644 --- a/includes/Notifications/Controller.php +++ b/includes/Notifications/Controller.php @@ -208,6 +208,25 @@ return $events; } + public function notifyFlowEnabledOnTalkpage( User $user ) { + if ( !class_exists( 'EchoEvent' ) ) { + // Nothing to do here. + return array(); + } + + $events = array(); + $events[] = EchoEvent::create( array( + 'type' => 'flow-enabled-on-talkpage', + 'agent' => $user, + 'title' => $user->getTalkPage(), + 'extra' => array( + 'notifyAgent' => true, + ), + ) ); + + return $events; + } + /** * Called when a new Post is added, whether it be a new topic or a reply. * Do not call directly, use notifyPostChange for new replies. diff --git a/includes/Notifications/Notifications.php b/includes/Notifications/Notifications.php index c2a5dea..15a556a 100644 --- a/includes/Notifications/Notifications.php +++ b/includes/Notifications/Notifications.php @@ -103,6 +103,22 @@ 'email-body-batch-message' => 'flow-notification-mention-email-batch-body', 'email-body-batch-params' => array( 'agent', 'subject', 'title', 'user' ), ) + $notificationTemplate, + 'flow-enabled-on-talkpage' => array( + 'section' => null, + 'user-locators' => array( + 'EchoUserLocator::locateTalkPageOwner' + ), + 'primary-link' => array( + 'message' => 'flow-notification-link-text-enabled-on-talkpage', + 'destination' => 'title' + ), + 'title-message' => 'flow-notification-enabled-on-talkpage-title-message', + 'title-params' => array( 'title' ), + 'email-subject-message' => 'flow-notification-enabled-on-talkpage-email-subject', + 'email-subject-params' => array( 'title' ), + 'email-body-batch-message' => 'flow-notification-enabled-on-talkpage-email-batch-body', + 'email-body-batch-params' => array( 'title' ), + ) + $notificationTemplate, ); return $notifications; diff --git a/maintenance/FlowUpdateBetaFeaturePreference.php b/maintenance/FlowUpdateBetaFeaturePreference.php new file mode 100644 index 0000000..8e69043 --- /dev/null +++ b/maintenance/FlowUpdateBetaFeaturePreference.php @@ -0,0 +1,93 @@ +<?php + +require_once ( getenv( 'MW_INSTALL_PATH' ) !== false + ? getenv( 'MW_INSTALL_PATH' ) . '/maintenance/Maintenance.php' + : dirname( __FILE__ ) . '/../../../maintenance/Maintenance.php' ); + +/** + * Sets Flow beta feature preference to true + * for users who are already using flow on + * their user talk page. + * + * @ingroup Maintenance + */ +class FlowUpdateBetaFeaturePreference extends LoggedUpdateMaintenance { + + public function __construct() { + $this->setBatchSize( 300 ); + } + + /** + * When the Flow beta feature is enable, it finds users + * who already have Flow enabled on their user talk page + * and opt them in the beta feature so their preferences + * and user talk page state are in sync. + * + * @return bool + * @throws MWException + */ + protected function doDBUpdates() { + global $wgFlowEnableOptInBetaFeature; + if ( !$wgFlowEnableOptInBetaFeature ) { + return true; + } + + $db = $this->getDB( DB_MASTER ); + + $innerQuery = $db->selectSQLText( + 'user_properties', + 'up_user', + array( + 'up_property' => BETA_FEATURE_FLOW_USER_TALK_PAGE, + 'up_value' => 1 + ) + ); + + $result = $db->select( + array( 'page', 'user' ), + 'user_id', + array( + 'page_content_model' => CONTENT_MODEL_FLOW_BOARD, + "user_id NOT IN($innerQuery)" + ), + __METHOD__, + array(), + array( + 'user' => array( 'JOIN', array( + 'page_namespace' => NS_USER_TALK, + "page_title = REPLACE(user_name, ' ', '_')" + ) ), + ) + ); + + $i = 0; + $users = UserArray::newFromResult( $result ); + foreach ( $users as $user ) { + $user->setOption( BETA_FEATURE_FLOW_USER_TALK_PAGE, 1 ); + $user->saveSettings(); + + if ( ++$i % $this->mBatchSize === 0 ) { + wfWaitForSlaves(); + } + } + + return true; + } + + /** + * Get the update key name to go in the update log table + * + * Returns a different key when the beta feature is enabled or disable + * so that enabling it would trigger this script + * to execute so it can correctly update users preferences. + * + * @return string + */ + protected function getUpdateKey() { + global $wgFlowEnableOptInBetaFeature; + return $wgFlowEnableOptInBetaFeature ? 'FlowBetaFeatureEnable' : 'FlowBetaFeatureDisable'; + } +} + +$maintClass = 'FlowUpdateBetaFeaturePreference'; // Tells it to run the class +require_once( RUN_MAINTENANCE_IF_MAIN ); diff --git a/tests/browser/features/opt_in.feature b/tests/browser/features/opt_in.feature new file mode 100644 index 0000000..f8ddd0a --- /dev/null +++ b/tests/browser/features/opt_in.feature @@ -0,0 +1,41 @@ +@chrome @firefox +@clean @login +@en.wikipedia.beta.wmflabs.org +Feature: Opt-in Flow beta feature + + Depends on having $wgFlowEnableOptInBetaFeature = true + and NS_USER_TALK not occupied by Flow. + + Background: + Given I am logged in as a new user + + Scenario: Opt-in: I don't have a talk page + When I enable Flow beta feature + Then my talk page is a Flow board + And a notification tells me about it + + Scenario: Opt-in: I have a wikitext talk page + Given my talk page has wiktext content + When I enable Flow beta feature + Then my talk page is a Flow board + And my flow board contains a link to my archived talk page + And my previous talk page is archived + + Scenario: Opt-out: I didn't have a talk page + Given I have Flow beta feature enabled + When I disable Flow beta feature + Then my Flow board is archived + And my talk page is deleted without redirect + + Scenario: Opt-out: I had a wikitext talk page + Given my talk page has wiktext content + And I have Flow beta feature enabled + When I disable Flow beta feature + Then my wikitext talk page is restored + And my Flow board is archived + + Scenario: Re-opt-in + Given I have used the Flow beta feature before + When I enable Flow beta feature + Then my talk page is my old Flow board + And my previous talk page is archived diff --git a/tests/browser/features/step_definitions/opt_in_steps.rb b/tests/browser/features/step_definitions/opt_in_steps.rb new file mode 100644 index 0000000..7f73af8 --- /dev/null +++ b/tests/browser/features/step_definitions/opt_in_steps.rb @@ -0,0 +1,106 @@ + +Given(/^I am logged in as a new user$/) do + @username = @data_manager.get 'New_user' + puts "New user: #{@username}" + api.create_account @username, password + visit(LoginPage).login_with @username, password +end + +When(/^I enable Flow beta feature$/) do + visit(SpecialPreferencesPage) do |page| + page.beta_features_element.when_present.click + page.check_flow_beta_feature + page.save_preferences + page.confirmation_element.when_present + end +end + +Then(/^my talk page is a Flow board$/) do + visit(UserTalkPage, using_params: { username: @username }) do |page| + page.flow.board_element.when_present + end +end + +Given(/^my talk page has wiktext content$/) do + talk_page = "User_talk:#{@username}" + @talk_page_content = 'this is the content of my talk page' + api.create_page talk_page, @talk_page_content +end + +Then(/^my previous talk page is archived$/) do + archive_name = "./User_talk:#{@username}/Archive_1" + archive_template = "User_talk:#{@username}".gsub '_', ' ' + visit(WikiPage, using_params: { page: archive_name }) do |page| + expect(page.content_element.when_present.text).to match @talk_page_content + expect(page.content_element.when_present.text).to match archive_template + end +end + +Given(/^I have Flow beta feature enabled$/) do + step 'I enable Flow beta feature' +end + +When(/^I disable Flow beta feature$/) do + visit(SpecialPreferencesPage) do |page| + page.beta_features_element.when_present.click + page.uncheck_flow_beta_feature + page.save_preferences + page.confirmation_element.when_present + end +end + +Then(/^my wikitext talk page is restored$/) do + talk_page_link = "User_talk:#{@username}".gsub '_', ' ' + visit(UserTalkPage, using_params: { username: @username }) do |page| + page.content_element.when_present + expect(page.content).to match @talk_page_content + expect(page.content).to_not match talk_page_link + end +end + +Then(/^my Flow board is archived$/) do + flow_archive_name = "./User_talk:#{@username}/Flow_Archive_1" + talk_page_link = "User_talk:#{@username}".gsub '_', ' ' + visit(WikiPage, using_params: { page: flow_archive_name }) do |page| + page.flow.board_element.when_present + expect(page.flow.header).to_not match talk_page_link + end +end + +Given(/^I have used the Flow beta feature before$/) do + step 'my talk page has wiktext content' + step 'I enable Flow beta feature' + @topic_title = @data_manager.get 'title' + api.action('flow', submodule: 'new-topic', page: "User_talk:#{@username}", nttopic: @topic_title, ntcontent: 'created via API') + step 'I disable Flow beta feature' +end + +Then(/^my talk page is my old Flow board$/) do + archive_name = "User_talk:#{@username}/Archive_1".gsub '_', ' ' + visit(UserTalkPage, using_params: { username: @username }) do |page| + expect(page.content_element.when_present.text).to match @topic_title + expect(page.flow.header).to match archive_name + end +end + +Then(/^my flow board contains a link to my archived talk page$/) do + archive_name = "User_talk:#{@username}/Archive_1".gsub '_', ' ' + visit(UserTalkPage, using_params: { username: @username }) do |page| + page.flow.board_element.when_present + expect(page.flow.header).to match archive_name + end +end + +Then(/^a notification tells me about it$/) do + visit(SpecialNotificationsPage) do |page| + expect(page.first_notification_element.when_present.text).to match 'New discussion system' + end +end + +Then(/^my talk page is deleted without redirect$/) do + visit(UserTalkPage, using_params: { username: @username }) do |page| + page.content_element.when_present + expect(page.content).to match 'This page has been deleted.' + expect(page.content).to match 'without leaving a redirect' + end +end diff --git a/tests/browser/features/support/data_manager.rb b/tests/browser/features/support/data_manager.rb index 7ed73df..1cbbdf4 100644 --- a/tests/browser/features/support/data_manager.rb +++ b/tests/browser/features/support/data_manager.rb @@ -4,7 +4,7 @@ end def get(part) - @data[part] = "#{part}-#{rand}" unless @data.key? part + @data[part] = "#{part}_#{Random.srand}" unless @data.key? part @data[part] end diff --git a/tests/browser/features/support/pages/abstract_flow_page.rb b/tests/browser/features/support/pages/abstract_flow_page.rb index 6a5ad0d..187d878 100644 --- a/tests/browser/features/support/pages/abstract_flow_page.rb +++ b/tests/browser/features/support/pages/abstract_flow_page.rb @@ -1,6 +1,5 @@ -require_relative 'wiki_page' - -class AbstractFlowPage < WikiPage +class AbstractFlowPage + include PageObject include FlowEditor page_section(:description, BoardDescription, class: 'flow-board-header') @@ -12,6 +11,8 @@ option.when_present.click end + a(:logout, css: "#pt-logout a") + # board component div(:flow_component, class: 'flow-component') div(:flow_board, class: 'flow-board') diff --git a/tests/browser/features/support/pages/flow_component.rb b/tests/browser/features/support/pages/flow_component.rb new file mode 100644 index 0000000..ac46bc4 --- /dev/null +++ b/tests/browser/features/support/pages/flow_component.rb @@ -0,0 +1,6 @@ +class FlowComponent + include PageObject + + div(:board, class: 'flow-board') + div(:header, class: 'flow-ui-boardDescriptionWidget-content') +end diff --git a/tests/browser/features/support/pages/new_wiki_page.rb b/tests/browser/features/support/pages/new_wiki_page.rb deleted file mode 100644 index f3fa990..0000000 --- a/tests/browser/features/support/pages/new_wiki_page.rb +++ /dev/null @@ -1,6 +0,0 @@ -require_relative 'wiki_page' - -class NewWikiPage < WikiPage - # MEDIAWIKI_URL must have this in $wgFlowOccupyNamespaces. - page_url "<%=params[:page]%>" -end diff --git a/tests/browser/features/support/pages/special_notifications_page.rb b/tests/browser/features/support/pages/special_notifications_page.rb new file mode 100644 index 0000000..42166e8 --- /dev/null +++ b/tests/browser/features/support/pages/special_notifications_page.rb @@ -0,0 +1,7 @@ +class SpecialNotificationsPage + include PageObject + + page_url "Special:Notifications" + + div(:first_notification, class: 'mw-echo-content', index: 0) +end diff --git a/tests/browser/features/support/pages/special_preferences_page.rb b/tests/browser/features/support/pages/special_preferences_page.rb new file mode 100644 index 0000000..84445f1 --- /dev/null +++ b/tests/browser/features/support/pages/special_preferences_page.rb @@ -0,0 +1,13 @@ +class SpecialPreferencesPage + include PageObject + + page_url "Special:Preferences" + + link(:beta_features, id: 'preftab-betafeatures') + + checkbox(:flow_beta_feature, id: 'mw-input-wpbeta-feature-flow-user-talk-page') + + button(:save_preferences, id: 'prefcontrol') + + div(:confirmation, text: 'Your preferences have been saved.') +end diff --git a/tests/browser/features/support/pages/user_talk_page.rb b/tests/browser/features/support/pages/user_talk_page.rb new file mode 100644 index 0000000..7ea50e1 --- /dev/null +++ b/tests/browser/features/support/pages/user_talk_page.rb @@ -0,0 +1,7 @@ +require_relative 'wiki_page' + +class UserTalkPage < WikiPage + include PageObject + + page_url "./User_talk:<%= params[:username]%>" +end diff --git a/tests/browser/features/support/pages/wiki_page.rb b/tests/browser/features/support/pages/wiki_page.rb index 3f0c5c9..42817fb 100644 --- a/tests/browser/features/support/pages/wiki_page.rb +++ b/tests/browser/features/support/pages/wiki_page.rb @@ -1,4 +1,9 @@ class WikiPage include PageObject + + page_url "<%=params[:page]%>" + a(:logout, css: "#pt-logout a") + div(:content, id: 'mw-content-text') + page_section(:flow, FlowComponent, class: 'flow-component') end diff --git a/tests/phpunit/Import/ArchiveNameHelperTest.php b/tests/phpunit/Import/ArchiveNameHelperTest.php new file mode 100644 index 0000000..1ccef03 --- /dev/null +++ b/tests/phpunit/Import/ArchiveNameHelperTest.php @@ -0,0 +1,118 @@ +<?php + +namespace Flow\Tests\Import; +use Title; +use Flow\Import\ArchiveNameHelper; + + +/** + * @group Flow + */ +class ArchiveNameHelperTest extends \MediaWikiTestCase { + + public function decideArchiveTitleProvider() { + return array( + array( + 'Selects the first pattern if n=1 does exist', + // expect + 'Talk:Flow/Archive 1', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d', '%s/Archive%d' ), + // existing titles + array(), + ), + + array( + 'Selects n=2 when n=1 exists', + // expect + 'Talk:Flow/Archive 2', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d' ), + // existing titles + array( 'Talk:Flow/Archive 1' ), + ), + + array( + 'Selects the second pattern if n=1 exists', + // expect + 'Talk:Flow/Archive2', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d', '%s/Archive%d' ), + // existing titles + array( 'Talk:Flow/Archive1' ), + ), + ); + } + /** + * @dataProvider decideArchiveTitleProvider + */ + public function testDecideArchiveTitle( $message, $expect, Title $source, array $formats, array $exists ) { + // flip so we can use isset + $existsByKey = array_flip( $exists ); + + $titleRepo = $this->getMock( 'Flow\Repository\TitleRepository' ); + $titleRepo->expects( $this->any() ) + ->method( 'exists' ) + ->will( $this->returnCallback( function( Title $title ) use ( $existsByKey ) { + return isset( $existsByKey[$title->getPrefixedText()] ); + } ) ); + + $archiveNameHelper = new ArchiveNameHelper(); + $result = $archiveNameHelper->decideArchiveTitle( $source, $formats, $titleRepo ); + $this->assertEquals( $expect, $result, $message ); + } + + public function findLatestArchiveTitleProvider() { + return array( + array( + 'Returns false if no archive exist', + // expect + false, + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d', '%s/Archive%d' ), + // existing titles + array(), + ), + + array( + 'Selects n=2 when n=2 exists', + // expect + 'Talk:Flow/Archive 2', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d' ), + // existing titles + array( 'Talk:Flow/Archive 1', 'Talk:Flow/Archive 2' ), + ), + + ); + } + /** + * @dataProvider findLatestArchiveTitleProvider + */ + public function testFindLatestArchiveTitle( $message, $expect, Title $source, array $formats, array $exists ) { + // flip so we can use isset + $existsByKey = array_flip( $exists ); + + $titleRepo = $this->getMock( 'Flow\Repository\TitleRepository' ); + $titleRepo->expects( $this->any() ) + ->method( 'exists' ) + ->will( $this->returnCallback( function( Title $title ) use ( $existsByKey ) { + return isset( $existsByKey[$title->getPrefixedText()] ); + } ) ); + + $archiveNameHelper = new ArchiveNameHelper(); + $result = $archiveNameHelper->findLatestArchiveTitle( $source, $formats, $titleRepo ); + $this->assertEquals( $expect, $result, $message ); + } + +} diff --git a/tests/phpunit/Import/ConverterTest.php b/tests/phpunit/Import/ConverterTest.php index 005a980..af1ad90 100644 --- a/tests/phpunit/Import/ConverterTest.php +++ b/tests/phpunit/Import/ConverterTest.php @@ -8,7 +8,6 @@ use Flow\Import\Importer; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Title; use User; /** @@ -20,63 +19,6 @@ 'Flow\Import\Converter', $this->createConverter() ); - } - - public function decideArchiveTitleProvider() { - return array( - array( - 'Selects the first pattern if n=1 does exist', - // expect - 'Talk:Flow/Archive 1', - // source title - Title::newFromText( 'Talk:Flow' ), - // formats - array( '%s/Archive %d', '%s/Archive%d' ), - // existing titles - array(), - ), - - array( - 'Selects n=2 when n=1 exists', - // expect - 'Talk:Flow/Archive 2', - // source title - Title::newFromText( 'Talk:Flow' ), - // formats - array( '%s/Archive %d' ), - // existing titles - array( 'Talk:Flow/Archive 1' ), - ), - - array( - 'Selects the second pattern if n=1 exists', - // expect - 'Talk:Flow/Archive2', - // source title - Title::newFromText( 'Talk:Flow' ), - // formats - array( '%s/Archive %d', '%s/Archive%d' ), - // existing titles - array( 'Talk:Flow/Archive1' ), - ), - ); - } - /** - * @dataProvider decideArchiveTitleProvider - */ - public function testDecideArchiveTitle( $message, $expect, Title $source, array $formats, array $exists ) { - // flip so we can use isset - $existsByKey = array_flip( $exists ); - - $titleRepo = $this->getMock( 'Flow\Repository\TitleRepository' ); - $titleRepo->expects( $this->any() ) - ->method( 'exists' ) - ->will( $this->returnCallback( function( Title $title ) use ( $existsByKey ) { - return isset( $existsByKey[$title->getPrefixedText()] ); - } ) ); - - $result = Converter::decideArchiveTitle( $source, $formats, $titleRepo ); - $this->assertEquals( $expect, $result, $message ); } protected function createConverter( diff --git a/tests/phpunit/Import/TemplateHelperTest.php b/tests/phpunit/Import/TemplateHelperTest.php new file mode 100644 index 0000000..f6a9ff4 --- /dev/null +++ b/tests/phpunit/Import/TemplateHelperTest.php @@ -0,0 +1,54 @@ +<?php + +namespace Flow\Tests\Import; + +use Flow\Import\TemplateHelper; + +class TemplateHelperTest extends \MediaWikiTestCase { + + public function removeFromHtmlDataProvider() { + return array( + array( // the template is NOT in the html + '<body data-parsoid="{stuff}"><p name="asdf">hi there</p></body>', + 'I am not a real template', + '<body data-parsoid="{stuff}"><p name="asdf">hi there</p></body>' + ), + array( // the template IS in the html ONCE + '<body data-parsoid="{stuff}"><p>hi there<span typeof="mw:Transclusion" data-mw=\'{"parts":[{"template":{"target":{"wt":"I am a template","href":"./Template:I_am_a_template"}}}]}\'></span></p></body>', + 'I am a template', + '<body data-parsoid="{stuff}"><p>hi there</p></body>' + ), + array( // the template IS in the html MANY TIMES + '<body data-parsoid="{stuff}"><p>a<span typeof="mw:Transclusion" data-mw=\'{"parts":[{"template":{"target":{"wt":"I am a template","href":"./Template:I_am_a_template"}}}]}\'></span>b<span typeof="mw:Transclusion" data-mw=\'{"parts":[{"template":{"target":{"wt":"I am a template","href":"./Template:I_am_a_template"}}}]}\'></span>c</p></body>', + 'I am a template', + '<body data-parsoid="{stuff}"><p>abc</p></body>' + ), + array( // somewhat malformed data-mw + '<body data-parsoid="{stuff}"><p>hi there<span typeof="mw:Transclusion" data-mw=\'{"parts":[{}]}\'></span></p></body>', + 'Template name', + '<body data-parsoid="{stuff}"><p>hi there<span typeof="mw:Transclusion" data-mw=\'{"parts":[{}]}\'></span></p></body>' + ), + array( // multinode template using 'about' attribute + '<body data-parsoid="{stuff}">' . + '<p>hi there</p>' . + '<span typeof="mw:Transclusion" about="#mwt5" data-mw=\'{"parts":[{"template":{"target":{"wt":"I am a template","href":"./Template:I_am_a_template"}}}]}\'></span>' . + '<span about="#mwt5">random sibling node</span>' . + '<span about="#mwt5">and then another one</span>' . + '</body>', + 'I am a template', + '<body data-parsoid="{stuff}"><p>hi there</p></body>' + ) + ); + } + + /** + * @dataProvider removeFromHtmlDataProvider + */ + public function testRemoveFromHtml( $originalHtml, $templateToRemove, $expectedHtml ) { + $actualHTML = TemplateHelper::removeFromHtml( $originalHtml, $templateToRemove ); + $this->assertEquals( + $expectedHtml, + $actualHTML ); + } + +} \ No newline at end of file -- To view, visit https://gerrit.wikimedia.org/r/230648 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: Ia9950c4eb1c0e5f912761a65c76c5a2b3b99c8ee Gerrit-PatchSet: 37 Gerrit-Project: mediawiki/extensions/Flow Gerrit-Branch: master Gerrit-Owner: Sbisson <sbis...@wikimedia.org> Gerrit-Reviewer: Catrope <roan.katt...@gmail.com> Gerrit-Reviewer: Mattflaschen <mflasc...@wikimedia.org> Gerrit-Reviewer: Matthias Mullie <mmul...@wikimedia.org> Gerrit-Reviewer: Mooeypoo <mor...@gmail.com> Gerrit-Reviewer: Sbisson <sbis...@wikimedia.org> Gerrit-Reviewer: Siebrand <siebr...@kitano.nl> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits