diff --git a/i18n/en.json b/i18n/en.json
index c10808570d..3a4d82a113 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1,551 +1,551 @@
{
"@metadata": {
"authors": [
"Erik Bernhardson",
"Matthias Mullie",
"Benny Situ",
"Andrew Garrett",
"Yuki Shira",
"Shahyar Ghobadpour",
"Jon Robson",
"Matthew Flaschen",
"Siebrand Mazeland",
"Amir E. Aharoni"
]
},
"enableflow": "Enable Flow",
"flow-desc": "Workflow management system",
"flow-talk-taken-over": "This talk page is using [https://www.mediawiki.org/wiki/Special:MyLanguage/Flow_Portal Flow].",
"flow-talk-username": "Flow talk page manager",
"log-name-flow": "Flow activity log",
"logentry-delete-flow-delete-post": "$1 {{GENDER:$2|deleted}} a [$4 post] on \"[[$3|$5]]\" on [[$6]]",
"logentry-delete-flow-restore-post": "$1 {{GENDER:$2|restored}} a [$4 post] on \"[[$3|$5]]\" on [[$6]]",
"logentry-suppress-flow-suppress-post": "$1 {{GENDER:$2|suppressed}} a [$4 post] on \"[[$3|$5]]\" on [[$6]]",
"logentry-suppress-flow-restore-post": "$1 {{GENDER:$2|deleted}} a [$4 post] on \"[[$3|$5]]\" on [[$6]]",
"logentry-delete-flow-delete-topic": "$1 {{GENDER:$2|deleted}} topic \"[[$3|$5]]\" on [[$6]]",
"logentry-delete-flow-restore-topic": "$1 {{GENDER:$2|restored}} topic \"[[$3|$5]]\" on [[$6]]",
"logentry-suppress-flow-suppress-topic": "$1 {{GENDER:$2|suppressed}} topic \"[[$3|$5]]\" on [[$6]]",
"logentry-suppress-flow-restore-topic": "$1 {{GENDER:$2|deleted}} topic \"[[$3|$5]]\" on [[$6]]",
"logentry-import-lqt-to-flow-topic": "[[$1|$2]] on [[$3]] was imported from LiquidThreads to Flow",
"flow-user-moderated": "Moderated user",
"flow-board-header-browse-topics-link": "Browse topics",
"flow-edit-header-link": "Edit header",
"flow-post-moderated-toggle-hide-show": "Show comment {{GENDER:$1|hidden}} by $2",
"flow-post-moderated-toggle-delete-show": "Show comment {{GENDER:$1|deleted}} by $2",
"flow-post-moderated-toggle-suppress-show": "Show comment {{GENDER:$1|suppressed}} by $2",
"flow-post-moderated-toggle-hide-hide": "Hide comment {{GENDER:$1|hidden}} by $2",
"flow-post-moderated-toggle-delete-hide": "Hide comment {{GENDER:$1|deleted}} by $2",
"flow-post-moderated-toggle-suppress-hide": "Hide comment {{GENDER:$1|suppressed}} by $2",
"flow-topic-moderated-reason-prefix": "Reason:",
"flow-hide-post-content": "This comment was {{GENDER:$1|hidden}} by $1 ([$2 history])",
"flow-hide-title-content": "This topic was {{GENDER:$1|hidden}} by $1",
"flow-lock-title-content": "This topic was {{GENDER:$1|locked}} by $1",
"flow-hide-header-content": "{{GENDER:$1|Hidden}} by $2",
"flow-hide-usertext": "$1",
"flow-delete-post-content": "This comment was {{GENDER:$1|deleted}} by $1 ([$2 history])",
"flow-delete-title-content": "This topic was {{GENDER:$1|deleted}} by $1",
"flow-delete-header-content": "{{GENDER:$1|Deleted}} by $2",
"flow-delete-usertext": "$1",
"flow-suppress-post-content": "This comment was {{GENDER:$1|suppressed}} by $1 ([$2 history])",
"flow-suppress-title-content": "This topic was {{GENDER:$1|suppressed}} by $1",
"flow-suppress-header-content": "{{GENDER:$1|Suppressed}} by $2",
"flow-suppress-usertext": "Username suppressed",
"flow-post-actions": "Actions",
"flow-topic-actions": "Actions",
"flow-cancel": "Cancel",
"flow-preview": "Preview",
"flow-show-change": "Show changes",
"flow-last-modified-by": "Last {{GENDER:$1|modified}} by $1",
"flow-system-usertext": "{{SITENAME}}",
"flow-stub-post-content": "''Due to a technical error, this post could not be retrieved.''",
"flow-newtopic-title-placeholder": "New topic",
"flow-newtopic-content-placeholder": "Post a new message to \"$1\"",
"flow-newtopic-header": "Add a new topic",
"flow-newtopic-save": "Add topic",
"flow-newtopic-start-placeholder": "Start a new topic",
"flow-newtopic-first-heading": "Start a new topic on $1",
"flow-summarize-topic-placeholder": "Please summarize this discussion",
"flow-reply-topic-placeholder": "{{GENDER:$1|Comment}} on \"$2\"",
"flow-reply-topic-title-placeholder": "Reply to \"$1\"",
"flow-reply-submit": "{{GENDER:$1|Reply}}",
"flow-reply-link": "{{GENDER:$1|Reply}}",
"flow-thank-link": "{{GENDER:$1|Thank}}",
"flow-lock-link": "{{GENDER:$1|Lock}}",
"flow-thank-link-title": "Publicly thank the poster",
"flow-history-action-suppress-post": "suppress",
"flow-history-action-delete-post": "delete",
"flow-history-action-hide-post": "hide",
"flow-history-action-unsuppress-post": "unsuppress",
"flow-history-action-undelete-post": "undelete",
"flow-history-action-unhide-post": "unhide",
"flow-history-action-restore-post": "restore",
"flow-history-action-lock-topic": "lock",
"flow-history-action-unlock-topic": "unlock",
"flow-post-interaction-separator": " • ",
"flow-post-edited": "Post {{GENDER:$1|edited}} by $1 $2",
"flow-post-action-view": "Permalink",
"flow-post-action-post-history": "History",
"flow-post-action-suppress-post": "Suppress",
"flow-post-action-delete-post": "Delete",
"flow-post-action-hide-post": "Hide",
"flow-post-action-edit-post": "Edit",
"flow-post-action-edit-post-submit": "Save changes",
"flow-post-action-unsuppress-post": "Unsuppress",
"flow-post-action-undelete-post": "Undelete",
"flow-post-action-unhide-post": "Unhide",
"flow-post-action-restore-post": "Restore",
"flow-post-action-undo-moderation": "Undo",
"flow-topic-action-view": "Permalink",
"flow-topic-action-watchlist": "Watchlist",
"flow-topic-action-edit-title": "Edit title",
"flow-topic-action-history": "History",
"flow-topic-action-hide-topic": "Hide topic",
"flow-topic-action-delete-topic": "Delete topic",
"flow-topic-action-lock-topic": "Lock topic",
"flow-topic-action-unlock-topic": "Unlock topic",
"flow-topic-action-summarize-topic": "Summarize",
"flow-topic-action-resummarize-topic": "Edit the topic summary",
"flow-topic-action-suppress-topic": "Suppress topic",
"flow-topic-action-unhide-topic": "Unhide topic",
"flow-topic-action-undelete-topic": "Undelete topic",
"flow-topic-action-unsuppress-topic": "Unsuppress topic",
"flow-topic-action-restore-topic": "Restore topic",
"flow-topic-action-undo-moderation": "Undo",
"flow-topic-notification-subscribe-title": "This topic has been added to {{GENDER:$1|your}} watchlist.",
"flow-topic-notification-subscribe-description": "{{GENDER:$1|You}} will receive notifications on all activities on this topic.",
"flow-board-notification-subscribe-title": "{{GENDER:$1|You're}} subscribed to this discussion board!",
"flow-board-notification-subscribe-description": "{{GENDER:$1|You}} will get a notification when a new topic is created on this board.",
"flow-error-http": "An error occurred while contacting the server.",
"flow-error-other": "An unexpected error occurred.",
"flow-error-external": "An error occurred.
The error message received was: $1",
"flow-error-edit-restricted": "You are not allowed to edit this post.",
"flow-error-topic-is-locked": "This topic is locked for any further activities.",
"flow-error-lock-moderated-post": "You cannot lock a moderated post.",
"flow-error-external-multi": "Errors were encountered.
$1",
"flow-error-missing-content": "Post has no content. Content is required to save a post.",
"flow-error-missing-summary": "Summary has no content. Content is required to save a summary.",
"flow-error-missing-title": "Topic has no title. Title is required to save a topic.",
"flow-error-parsoid-failure": "Unable to parse content due to a Parsoid failure.",
"flow-error-missing-replyto": "No \"replyTo\" parameter was supplied. This parameter is required for the \"reply\" action.",
"flow-error-invalid-replyto": "\"replyTo\" parameter was invalid. The specified post could not be found.",
"flow-error-delete-failure": "Deletion of this item failed.",
"flow-error-hide-failure": "Hiding this item failed.",
"flow-error-missing-postId": "No \"postId\" parameter was supplied. This parameter is required to manipulate a post.",
"flow-error-invalid-postId": "\"postId\" parameter was invalid. The specified post ($1) could not be found.",
"flow-error-restore-failure": "Restoration of this item failed.",
"flow-error-invalid-moderation-state": "An invalid value for a parameter ('moderationState') was submitted to the Flow API.",
"flow-error-invalid-moderation-reason": "Please provide a reason for the moderation.",
"flow-error-not-allowed": "Insufficient permissions to execute this action.",
"flow-error-not-allowed-hide": "This topic has been hidden.",
"flow-error-not-allowed-reply-to-hide-topic": "You cannot reply because this topic has been hidden.",
"flow-error-not-allowed-delete": "This topic has been deleted.",
"flow-error-not-allowed-reply-to-delete-topic": "You cannot reply because this topic has been deleted.",
"flow-error-not-allowed-suppress": "This topic has been deleted.",
"flow-error-not-allowed-reply-to-suppress-topic": "You cannot reply because this topic has been deleted.",
"flow-error-not-allowed-hide-extract": "This topic has been hidden. The hide log for the topic is provided below for reference.",
"flow-error-not-allowed-delete-extract": "This topic has been deleted. The deletion log for the topic is provided below for reference.",
"flow-error-not-allowed-reply-to-delete-topic-extract": "You cannot reply because this topic has been deleted. The deletion log for the topic is provided below for reference.",
"flow-error-not-allowed-suppress-extract": "This topic has been deleted. The deletion log for the topic is provided below for reference.",
"flow-error-not-allowed-reply-to-suppress-topic-extract": "You cannot reply because this topic has been suppressed. The suppression log for the topic is provided below for reference.",
"flow-error-title-too-long": "Topic titles are restricted to $1 {{PLURAL:$1|byte|bytes}}.",
"flow-error-no-existing-workflow": "This workflow does not yet exist.",
"flow-error-not-a-post": "Topic title cannot be saved as a post.",
"flow-error-missing-header-content": "Header has no content. Content is required to save a header.",
"flow-error-missing-prev-revision-identifier": "Previous revision identifier is missing.",
"flow-error-prev-revision-mismatch": "Another user just edited this post a few seconds ago. Are {{GENDER:$3|you}} sure you want to overwrite the recent change?",
"flow-error-prev-revision-does-not-exist": "Could not find the previous revision.",
"flow-error-core-topic-deletion": "To delete a topic, use the ... menu on the Flow board or [$1 topic page]. Do not visit action=delete for the topic directly.",
"flow-error-default": "An error has occurred.",
"flow-error-invalid-input": "Invalid value was provided for loading Flow content.",
"flow-error-invalid-title": "Invalid page title was provided.",
"flow-error-invalid-action": "{{int:nosuchactiontext}}",
"flow-error-fail-load-history": "Failed to load history content.",
"flow-error-missing-revision": "Could not find a revision to load Flow content.",
"flow-error-fail-commit": "Failed to save the Flow content.",
"flow-error-insufficient-permission": "Insufficient permission to access the content.",
"flow-error-revision-comparison": "Diff operation can only be done for two revisions belonging to the same post.",
"flow-error-missing-topic-title": "Could not find the topic title for current workflow.",
"flow-error-missing-metadata": "Could not find required metadata for this revision.",
"flow-error-fail-load-data": "Failed to load the requested data.",
"flow-error-invalid-workflow": "Could not find the requested workflow.",
"flow-error-process-data": "An error has occurred while processing the data in your request.",
"flow-error-process-wikitext": "An error has occurred while processing HTML/wikitext conversion.",
"flow-error-no-index": "Failed to find an index to perform data search.",
"flow-error-no-render": "The specified action was not recognized.",
"flow-error-no-commit": "The specified action could not be saved.",
"flow-error-fetch-after-lock": "An error was encountered when requesting the new data. The lock/unlock operation succeeded just fine, though. The error message was: $1",
"flow-error-content-too-long": "The content is too large. Content after expansion is limited to $1 {{PLURAL:$1|byte|bytes}}.",
"flow-error-move-topic": "Moving a topic page is currently not supported.",
"flow-error-move-no-create-permissions": "The \"{{int:right-flow-create-board}}\" permission is required to move a Flow board.",
"flow-error-invalid-topic-uuid-title": "Bad title",
"flow-error-invalid-topic-uuid": "The requested page title was invalid. Pages in the Topic namespace are automatically created by Flow.",
"flow-error-unknown-workflow-id-title": "Unknown topic",
"flow-error-unknown-workflow-id": "The requested topic does not exist.",
"flow-error-search": "We could not complete your search due to a temporary problem. Please try again later.",
"flow-edit-header-placeholder": "Describe this discussion board",
"flow-edit-header-submit": "Save header",
"flow-edit-header-submit-overwrite": "Overwrite header",
"flow-summarize-topic-submit": "Summarize",
"flow-summarize-topic-submit-overwrite": "Overwrite summary",
"flow-lock-topic-submit": "Lock topic",
"flow-lock-topic-submit-overwrite": "Overwrite lock topic summary",
"flow-unlock-topic-submit": "Unlock topic",
"flow-unlock-topic-submit-overwrite": "Overwrite unlock topic summary",
"flow-edit-title-submit": "Change title",
"flow-edit-title-submit-overwrite": "Overwrite title",
"flow-edit-post-submit": "Submit changes",
"flow-edit-post-submit-overwrite": "Overwrite changes",
"flow-rev-message-edit-post": "$1 {{GENDER:$2|edited}} a [$3 comment] on \"$4\"",
"flow-rev-message-edit-post-recentchanges": "$1",
"flow-rev-message-edit-post-recentchanges-summary": "{{GENDER:$2|Edited}} a post",
"flow-rev-message-edit-post-contributions": "",
"flow-rev-message-edit-post-irc": "$2 {{GENDER:$2|edited}} a comment on \"$4\"",
"flow-rev-message-reply": "$1 [$3 {{GENDER:$2|commented}}] on \"$4\" ($5)",
"flow-rev-message-reply-recentchanges": "$1",
"flow-rev-message-reply-contributions": "",
"flow-rev-message-reply-irc": "$2 {{GENDER:$2|commented}} on \"$4\" ($5)",
"flow-rev-message-reply-bundle": "$1 {{PLURAL:$1|comment|comments}} {{PLURAL:$1|was|were}} added",
"flow-rev-message-new-post": "$1 {{GENDER:$2|created}} the topic \"[$3 $4]\"",
"flow-rev-message-new-post-recentchanges": "$1",
"flow-rev-message-new-post-recentchanges-summary": "{{GENDER:$2|Created}} new topic",
"flow-rev-message-new-post-contributions": "",
"flow-rev-message-new-post-irc": "$2 {{GENDER:$2|created}} the topic \"$4\"",
"flow-rev-message-edit-title": "$1 {{GENDER:$2|changed}} the topic title from \"$5\" to \"[$3 $4]\"",
"flow-rev-message-edit-title-irc": "$2 {{GENDER:$2|changed}} the topic title from \"$5\" to \"$4\"",
"flow-rev-message-create-header": "$1 {{GENDER:$2|created}} the header",
"flow-rev-message-create-header-irc": "$2 {{GENDER:$2|created}} the header",
"flow-rev-message-edit-header": "$1 {{GENDER:$2|edited}} the header",
"flow-rev-message-edit-header-irc": "$2 {{GENDER:$2|edited}} the header",
"flow-rev-message-create-topic-summary": "$1 {{GENDER:$2|created}} topic summary on $3",
"flow-rev-message-create-topic-summary-irc": "$2 {{GENDER:$2|created}} topic summary on $3",
"flow-rev-message-edit-topic-summary": "$1 {{GENDER:$2|edited}} topic summary on $3",
"flow-rev-message-edit-topic-summary-irc": "$2 {{GENDER:$2|edited}} topic summary on $3",
"flow-rev-message-hid-post": "$1 {{GENDER:$2|hid}} a [$4 comment] on \"$6\" ($5)",
"flow-rev-message-hid-post-irc": "$2 {{GENDER:$2|hid}} a comment on \"$6\" ($5)",
"flow-rev-message-deleted-post": "$1 {{GENDER:$2|deleted}} a [$4 comment] on \"$6\" ($5)",
"flow-rev-message-deleted-post-irc": "$2 {{GENDER:$2|deleted}} a comment on \"$6\" ($5)",
"flow-rev-message-suppressed-post": "$1 {{GENDER:$2|suppressed}} a [$4 comment] on \"$6\" ($5)",
"flow-rev-message-suppressed-post-irc": "$2 {{GENDER:$2|suppressed}} a comment on \"$6\" ($5)",
"flow-rev-message-restored-post": "$1 {{GENDER:$2|restored}} a [$4 comment] on \"$6\" ($5)",
"flow-rev-message-restored-post-irc": "$2 {{GENDER:$2|restored}} a comment on \"$6\" ($5)",
"flow-rev-message-hid-topic": "$1 {{GENDER:$2|hid}} the [$4 topic] \"$6\" ($5)",
"flow-rev-message-hid-topic-irc": "$2 {{GENDER:$2|hid}} the topic \"$6\" ($5)",
"flow-rev-message-deleted-topic": "$1 {{GENDER:$2|deleted}} the [$4 topic] \"$6\" ($5)",
"flow-rev-message-deleted-topic-irc": "$2 {{GENDER:$2|deleted}} the topic \"$6\" ($5)",
"flow-rev-message-suppressed-topic": "$1 {{GENDER:$2|suppressed}} the [$4 topic] \"$6\" ($5)",
"flow-rev-message-suppressed-topic-irc": "$2 {{GENDER:$2|suppressed}} the topic \"$6\" ($5)",
"flow-rev-message-locked-topic": "$1 {{GENDER:$2|locked}} the [$4 topic] $6 ($5)",
"flow-rev-message-locked-topic-irc": "$2 {{GENDER:$2|locked}} the topic $6 ($5)",
"flow-rev-message-restored-topic": "$1 {{GENDER:$2|restored}} the [$4 topic] \"$6\" ($5)",
"flow-rev-message-restored-topic-irc": "$2 {{GENDER:$2|restored}} the topic \"$6\" ($5)",
"flow-rc-topic-of-board": "$1 on $2",
"flow-board-history": "\"$1\" history",
"flow-board-history-empty": "This board currently has no history.",
"flow-topic-history": "\"$1\" topic history",
"flow-post-history": "\"Comment by {{GENDER:$2|$2}}\" post history",
"flow-history-last4": "Last 4 hours",
"flow-history-day": "Today",
"flow-history-week": "Last week",
"flow-history-pages-topic": "Appears on [$1 \"$2\" board]",
"flow-history-pages-post": "Appears on [$1 $2]",
"flow-topic-comments": "{{PLURAL:$1|$1 comment|$1 comments|0={{GENDER:$2|Be the first}} to comment!}}",
"flow-comment-restored": "Restored comment",
"flow-comment-deleted": "Deleted comment",
"flow-comment-hidden": "Hidden comment",
"flow-comment-moderated": "Moderated comment",
"flow-last-modified": "Last modified about $1",
"flow-workflow": "workflow",
"flow-notification-reply": "[$5 $2]
$1 {{GENDER:$1|responded}} on '''$4'''.",
"flow-notification-reply-bundle": "[$4 $2]
$1 and $5 {{PLURAL:$6|other|others}} {{GENDER:$1|responded}} on '''$3'''.",
"flow-notification-edit": "[$6 $2]
$1 has {{GENDER:$1|edited}} your [$5 post] on [[$3|$4]].",
"flow-notification-edit-bundle": "$1 and $5 {{PLURAL:$6|other|others}} {{GENDER:$1|edited}} a [$4 post] in \"$2\" on \"$3\".",
"flow-notification-newtopic": "[$5 $4]
$1 {{GENDER:$1|created}} a new topic on '''$3'''.",
"flow-notification-newtopic-bundle": "{{PLURAL:$1|$1|250=250+}} new {{PLURAL:$1|topic|topics}} on '''[$3 $2]'''",
"flow-notification-rename": "$1 {{GENDER:$1|changed}} the title of [$2 $3] to \"$4\" on [[$5|$6]].",
"flow-notification-mention": "$1 {{GENDER:$1|mentioned}} {{GENDER:$5|you}} in {{GENDER:$1|his|her|their}} [$2 post] in \"$3\" on \"$4\".",
"flow-notification-link-text-view-post": "View post",
"flow-notification-link-text-view-topic": "View topic",
"flow-notification-reply-email-subject": "$2 on $3",
"flow-notification-reply-email-batch-body": "$1 {{GENDER:$1|responded}} to \"$2\" on \"$3\"",
"flow-notification-reply-email-batch-bundle-body": "$1 and $4 {{PLURAL:$5|other|others}} {{GENDER:$1|responded}} to \"$2\" on \"$3\"",
"flow-notification-mention-email-subject": "$1 {{GENDER:$1|mentioned}} {{GENDER:$3|you}} on \"$2\"",
"flow-notification-mention-email-batch-body": "$1 {{GENDER:$1|mentioned}} {{GENDER:$4|you}} in {{GENDER:$1|his|her|their}} post in \"$2\" on \"$3\"",
"flow-notification-edit-email-subject": "$1 {{GENDER:$1|edited}} a post",
"flow-notification-edit-email-batch-body": "$1 {{GENDER:$1|edited}} a post in \"$2\" on \"$3\"",
"flow-notification-edit-email-batch-bundle-body": "$1 and $4 {{PLURAL:$5|other|others}} {{GENDER:$1|edited}} a post in \"$2\" on \"$3\"",
"flow-notification-rename-email-subject": "$1 {{GENDER:$1|renamed}} your topic",
"flow-notification-rename-email-batch-body": "$1 {{GENDER:$1|renamed}} your topic \"$2\" to \"$3\" on \"$4\"",
"flow-notification-newtopic-email-subject": "$1 {{GENDER:$1|created}} a new topic on \"$2\"",
"flow-notification-newtopic-email-batch-body": "$1 {{GENDER:$1|created}} a new topic with the title \"$2\" on $3",
"echo-category-title-flow-discussion": "Flow",
"echo-pref-tooltip-flow-discussion": "Notify me when actions related to me occur in Flow.",
"flow-link-post": "post",
"flow-link-topic": "topic",
"flow-link-board": "$1",
"flow-link-history": "history",
"flow-link-post-revision": "post revision",
"flow-link-topic-revision": "topic revision",
"flow-link-header-revision": "header revision",
"flow-link-summary-revision": "summary revision",
"flow-moderation-title-suppress-post": "Suppress post?",
"flow-moderation-title-delete-post": "Delete post?",
"flow-moderation-title-hide-post": "Hide post?",
"flow-moderation-title-unsuppress-post": "Unsuppress post?",
"flow-moderation-title-undelete-post": "Undelete post?",
"flow-moderation-title-unhide-post": "Unhide post?",
"flow-moderation-placeholder-suppress-post": "Please {{GENDER:$3|explain}} why you're suppressing this post.",
"flow-moderation-placeholder-delete-post": "Please {{GENDER:$3|explain}} why you're deleting this post.",
"flow-moderation-placeholder-hide-post": "Please {{GENDER:$3|explain}} why you're hiding this post.",
"flow-moderation-placeholder-unsuppress-post": "Please {{GENDER:$3|explain}} why you're unsuppressing this post.",
"flow-moderation-placeholder-undelete-post": "Please {{GENDER:$3|explain}} why you're undeleting this post.",
"flow-moderation-placeholder-unhide-post": "Please {{GENDER:$3|explain}} why you're unhiding this post.",
"flow-moderation-confirm-suppress-post": "Suppress",
"flow-moderation-confirm-delete-post": "Delete",
"flow-moderation-confirm-hide-post": "Hide",
"flow-moderation-confirm-unsuppress-post": "Unsuppress",
"flow-moderation-confirm-undelete-post": "Undelete",
"flow-moderation-confirm-unhide-post": "Unhide",
"flow-moderation-confirm-suppress-topic": "Suppress",
"flow-moderation-confirm-delete-topic": "Delete",
"flow-moderation-confirm-hide-topic": "Hide",
"flow-moderation-confirm-lock-topic": "Lock",
"flow-moderation-confirm-unsuppress-topic": "Unsuppress",
"flow-moderation-confirm-undelete-topic": "Undelete",
"flow-moderation-confirm-unhide-topic": "Unhide",
"flow-moderation-confirm-unlock-topic": "Unlock",
"flow-moderation-confirmation-suppress-post": "The post was successfully suppressed.\n{{GENDER:$2|Consider}} giving $1 feedback on this post.",
"flow-moderation-confirmation-delete-post": "The post was successfully deleted.\n{{GENDER:$2|Consider}} giving $1 feedback on this post.",
"flow-moderation-confirmation-hide-post": "The post was successfully hidden.\n{{GENDER:$2|Consider}} giving $1 feedback on this post.",
"flow-moderation-confirmation-unsuppress-post": "You have successfully unsuppressed the above post.",
"flow-moderation-confirmation-undelete-post": "You have successfully undeleted the above post.",
"flow-moderation-confirmation-unhide-post": "You have successfully unhidden the above post.",
"flow-moderation-confirmation-suppress-topic": "This topic has been suppressed.",
"flow-moderation-confirmation-delete-topic": "This topic has been deleted.",
"flow-moderation-confirmation-hide-topic": "This topic has been hidden.",
"flow-moderation-confirmation-unsuppress-topic": "You have successfully unsuppressed this topic.",
"flow-moderation-confirmation-undelete-topic": "You have successfully undeleted this topic.",
"flow-moderation-confirmation-unhide-topic": "You have successfully unhidden this topic.",
"flow-moderation-title-suppress-topic": "Suppress topic?",
"flow-moderation-title-delete-topic": "Delete topic?",
"flow-moderation-title-hide-topic": "Hide topic?",
"flow-moderation-title-unsuppress-topic": "Unsuppress topic?",
"flow-moderation-title-undelete-topic": "Undelete topic?",
"flow-moderation-title-unhide-topic": "Unhide topic?",
"flow-moderation-placeholder-suppress-topic": "Please {{GENDER:$3|explain}} why you're suppressing this topic.",
"flow-moderation-placeholder-delete-topic": "Please {{GENDER:$3|explain}} why you're deleting this topic.",
"flow-moderation-placeholder-hide-topic": "Please {{GENDER:$3|explain}} why you're hiding this topic.",
"flow-moderation-placeholder-lock-topic": "Please {{GENDER:$3|explain}} why you're locking this topic.",
"flow-moderation-placeholder-unsuppress-topic": "Please {{GENDER:$3|explain}} why you're unsuppressing this topic.",
"flow-moderation-placeholder-undelete-topic": "Please {{GENDER:$3|explain}} why you're undeleting this topic.",
"flow-moderation-placeholder-unhide-topic": "Please {{GENDER:$3|explain}} why you're unhiding this topic.",
"flow-moderation-placeholder-unlock-topic": "Please {{GENDER:$3|explain}} why you're unlocking this topic.",
"flow-topic-permalink-warning": "This topic was started on [$2 $1]",
"flow-topic-permalink-warning-user-board": "This topic was started on [$2 {{GENDER:$1|$1}}'s board]",
"flow-revision-permalink-warning-post": "This is a permanent link to a single version of this post.\nThis version is from $1.\nYou can see the [$5 differences from the previous version], or view other versions on the [$4 post history page].",
"flow-revision-permalink-warning-post-first": "This is a permanent link to the first version of this post.\nYou can view later versions on the [$4 post history page].",
"flow-revision-permalink-warning-postsummary": "This is a permanent link to a single version of the summary for this post. This version is from $1.\nYou can see the [$5 differences from the previous version], or view other versions on the [$4 post history page].",
"flow-revision-permalink-warning-postsummary-first": "This is a permanent link to the first version of this post summary.\nYou can view later versions on the [$4 post history page].",
"flow-revision-permalink-warning-header": "This is a permanent link to a single version of the header.\nThis version is from $1. You can see the [$3 differences from the previous version], or view other versions on the [$2 board history page].",
"flow-revision-permalink-warning-header-first": "This is a permanent link to the first version of the header.\nYou can view later versions on the [$2 board history page].",
"flow-compare-revisions-revision-header": "Version by {{GENDER:$2|$2}} from $1",
"flow-compare-revisions-header-post": "This page shows the {{GENDER:$3|changes}} between two versions of a post by $3 in the topic \"[$5 $2]\" on [$4 $1].\nYou can see other versions of this post at its [$6 history page].",
"flow-compare-revisions-header-postsummary": "This page shows the changes between two versions of a post summary in the post \"[$4 $2]\" on [$3 $1].\nYou can see other versions of this post at its [$5 history page].",
"flow-compare-revisions-header-header": "This page shows the {{GENDER:$2|changes}} between two versions of the header on [$3 $1].\nYou can see other versions of the header at its [$4 history page].",
"action-flow-create-board": "create Flow boards in any location",
"right-flow-create-board": "Create Flow boards in any location",
"right-flow-hide": "Hide Flow topics and posts",
"right-flow-lock": "Lock Flow topics",
"right-flow-delete": "Delete Flow topics and posts",
"right-flow-edit-post": "Edit Flow posts by other users",
"right-flow-suppress": "Suppress Flow revisions",
"flow-terms-of-use-new-topic": "By clicking \"{{int:flow-newtopic-save}}\", you agree to the terms of use for this wiki.",
"flow-terms-of-use-reply": "By clicking \"{{int:flow-reply-submit}}\", you agree to the terms of use for this wiki.",
"flow-terms-of-use-edit": "By saving your changes, you agree to the terms of use for this wiki.",
"flow-anon-warning": "You are not logged in. To receive attribution with your name instead of your IP address, you can [$1 log in] or [$2 create an account].",
"flow-cancel-warning": "You have entered text in this form. Are you sure you want to discard it?",
"flow-topic-first-heading": "Topic on $1",
"flow-topic-html-title": "$1 on $2",
"flow-topic-count": "Topics ($1)",
"flow-load-more": "Load more",
"flow-no-more-fwd": "There are no older topics",
"flow-newest-topics": "Newest topics",
"flow-recent-topics": "Recently active topics",
"flow-sorting-tooltip-newest": "{{GENDER:|You}} are currently reading the newest topics first. Click for more sorting options.",
"flow-sorting-tooltip-recent": "{{GENDER:|You}} are currently reading the most recently active topics first. Click for more sorting options.",
"flow-toggle-small-topics": "Switch to small topics view",
"flow-toggle-topics": "Switch to topics only view",
"flow-toggle-topics-posts": "Switch to topics and posts view",
"flow-terms-of-use-summarize": "By clicking \"{{int:flow-summarize-topic-submit}}\", you agree to the terms of use for this wiki.",
"flow-terms-of-use-lock-topic": "By clicking \"{{int:flow-lock-topic-submit}}\", you agree to the terms of use for this wiki.",
"flow-terms-of-use-unlock-topic": "By clicking \"{{int:flow-unlock-topic-submit}}\", you agree to the terms of use for this wiki.",
"flow-whatlinkshere-post": "from a [$1 post]",
"flow-whatlinkshere-header": "from the [$1 header]",
"flow-whatlinkshere-post-summary": "from the [$1 summary]",
"flow": "Flow",
"flow-special-desc": "This special page redirects to a Flow workflow or a Flow post given a UUID.",
"flow-special-type": "Type",
"flow-special-type-post": "Post",
"flow-special-type-workflow": "Workflow",
"flow-special-uuid": "UUID",
"flow-special-invalid-uuid": "Could not find content matching the type and the UUID.",
"flow-special-enableflow-legend": "Enable Flow on a new page",
"flow-special-enableflow-page": "Page to enable Flow on",
"flow-special-enableflow-header": "Initial header of Flow board (wikitext)",
"flow-special-enableflow-board-already-exists": "There is already a Flow board at [[$1]].",
"flow-special-enableflow-invalid-title": "The provided page is not a valid page title",
"flow-special-enableflow-page-already-exists": "There is already a non-Flow page at [[$1]]. If you still want to locate a Flow board there, please move the existing page to an archive, delete the redirect, then use Special:EnableFlow again. Include the archive name in the header.",
"flow-special-enableflow-confirmation": "You have successfully created a Flow board at [[$1]].",
"flow-spam-confirmedit-form": "Please confirm you are a human by solving the below captcha: $1",
"flow-preview-warning": "You are seeing a preview. Click \"{{int:flow-newtopic-save}}\" to post, or click \"{{int:flow-preview-return-edit-post}}\" to continue writing.",
"flow-preview-return-edit-post": "Keep editing",
"flow-anonymous": "Anonymous",
"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?",
"flow-post-undo-hide": "undo hide",
"flow-post-undo-delete": "undo delete",
"flow-post-undo-suppress": "undo suppress",
"flow-topic-undo-hide": "undo hide",
"flow-topic-undo-delete": "undo delete",
"flow-topic-undo-suppress": "undo suppress",
"flow-importer-lqt-moved-thread-template": "LQT Moved thread stub converted to Flow",
"flow-importer-lqt-converted-template": "LQT page converted to Flow",
"flow-importer-lqt-converted-archive-template": "Archive for converted LQT page",
"flow-importer-wt-converted-template": "Wikitext talk page converted to Flow",
"flow-importer-wt-converted-archive-template": "Archive for converted wikitext talk page",
- "flow-importer-lqt-suppressed-user-template": "This revision was imported from LiquidThreads with a supressed user. It has been reassigned to the current user.",
+ "flow-importer-lqt-suppressed-user-template": "LQT post imported with supressed user",
"apihelp-flow-description": "Allows actions to be taken on Flow pages.",
"apihelp-flow-param-submodule": "The Flow submodule to invoke.",
"apihelp-flow-param-page": "The page to take the action on.",
"apihelp-flow-param-render": "Set this to something to include a block-specific rendering in the output.",
"apihelp-flow-example-1": "Edit the header of \"[[Talk:Sandbox]]\"",
"apihelp-flow+close-open-topic-description": "Deprecated in favor of [[Special:ApiHelp/flow+lock-topic|action=flow&submodule=lock-topic]].",
"apihelp-flow+close-open-topic-param-moderationState": "State to put topic in, either locked or unlocked.",
"apihelp-flow+close-open-topic-param-reason": "Reason for locking or unlocking the topic.",
"apihelp-flow+edit-header-description": "Edits a board's header.",
"apihelp-flow+edit-header-param-prev_revision": "Revision ID of the current header revision, to check for edit conflicts.",
"apihelp-flow+edit-header-param-content": "Content for header.",
"apihelp-flow+edit-header-param-format": "Format of the header (wikitext|html)",
"apihelp-flow+edit-header-example-1": "Edit the header of [[Talk:Sandbox]]",
"apihelp-flow+edit-header-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+edit-post-description": "Edits a post's content.",
"apihelp-flow+edit-post-param-postId": "Post ID.",
"apihelp-flow+edit-post-param-prev_revision": "Revision ID of the current post revision, to check for edit conflicts.",
"apihelp-flow+edit-post-param-content": "Content for post.",
"apihelp-flow+edit-post-param-format": "Format of the post content (wikitext|html)",
"apihelp-flow+edit-post-example-1": "Edit a post in [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+edit-post-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+edit-title-description": "Edits a topic's title.",
"apihelp-flow+edit-title-param-prev_revision": "Revision ID of the current title revision, to check for edit conflicts.",
"apihelp-flow+edit-title-param-content": "Content for title.",
"apihelp-flow+edit-title-example-1": "Edit the title of [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+edit-title-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+edit-topic-summary-description": "Edits a topic summary's content.",
"apihelp-flow+edit-topic-summary-param-prev_revision": "Revision ID of the current topic summary revision, if any, to check for edit conflicts.",
"apihelp-flow+edit-topic-summary-param-summary": "Content for the summary.",
"apihelp-flow+edit-topic-summary-param-format": "Format of the summary (wikitext|html)",
"apihelp-flow+edit-topic-summary-example-1": "Edit the summary of [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+edit-topic-summary-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+lock-topic-description": "Lock or unlock a Flow topic.",
"apihelp-flow+lock-topic-param-moderationState": "State to put topic in, either locked or unlocked.",
"apihelp-flow+lock-topic-param-reason": "Reason for locking or unlocking the topic.",
"apihelp-flow+lock-topic-example-1": "Lock [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+lock-topic-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+moderate-post-description": "Moderates a Flow post.",
"apihelp-flow+moderate-post-param-moderationState": "What level to moderate at.",
"apihelp-flow+moderate-post-param-reason": "Reason for moderation.",
"apihelp-flow+moderate-post-param-postId": "ID of the post to moderate.",
"apihelp-flow+moderate-post-example-1": "Delete a post on topic [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+moderate-post-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+moderate-topic-description": "Moderates a Flow topic.",
"apihelp-flow+moderate-topic-param-moderationState": "What level to moderate at.",
"apihelp-flow+moderate-topic-param-reason": "Reason for moderation.",
"apihelp-flow+moderate-topic-example-1": "Delete the topic [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+moderate-topic-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+new-topic-description": "Creates a new Flow topic on the given workflow.",
"apihelp-flow+new-topic-param-topic": "Text for new topic title.",
"apihelp-flow+new-topic-param-content": "Content for the topic's initial reply.",
"apihelp-flow+new-topic-param-format": "Format of the new topic's initial reply (wikitext|html)",
"apihelp-flow+new-topic-example-1": "Create a new topic on [[Talk:Sandbox]]",
"apihelp-flow+new-topic-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+reply-description": "Replies to a post.",
"apihelp-flow+reply-param-replyTo": "Post ID to reply to.",
"apihelp-flow+reply-param-content": "Content for new post.",
"apihelp-flow+reply-param-format": "Format of the new post (wikitext|html)",
"apihelp-flow+reply-example-1": "Reply to a post on [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+reply-param-metadataonly": "Whether to include only metadata about the new content, excluding everything else",
"apihelp-flow+view-header-description": "View a board header.",
"apihelp-flow+view-header-param-format": "Format to return the content in.",
"apihelp-flow+view-header-param-revId": "Load this revision, instead of the most recent.",
"apihelp-flow+view-header-example-1": "Fetch the header of [[Talk:Sandbox]] as wikitext",
"apihelp-flow+view-post-description": "View a post.",
"apihelp-flow+view-post-param-postId": "ID of the post to view.",
"apihelp-flow+view-post-param-format": "Format to return the content in.",
"apihelp-flow+view-post-example-1": "Fetch the content of a post on [[Topic:S2tycnas4hcucw8w]] as wikitext",
"apihelp-flow+view-topic-description": "View a topic.",
"apihelp-flow+view-topic-example-1": "View [[Topic:S2tycnas4hcucw8w]]",
"apihelp-flow+view-topic-summary-description": "View a topic summary.",
"apihelp-flow+view-topic-summary-param-format": "Format to return the content in.",
"apihelp-flow+view-topic-summary-param-revId": "Load this revision, instead of the most recent.",
"apihelp-flow+view-topic-summary-example-1": "View the summary for [[Topic:S2tycnas4hcucw8w]] as wikitext",
"apihelp-flow+view-topiclist-description": "View a list of topics.",
"apihelp-flow+view-topiclist-param-offset-dir": "Direction to order the topics.",
"apihelp-flow+view-topiclist-param-sortby": "Sorting option of the topics.",
"apihelp-flow+view-topiclist-param-savesortby": "Save sortby option, if set.",
"apihelp-flow+view-topiclist-param-offset-id": "Offset value (in UUID format) to start fetching topics at.",
"apihelp-flow+view-topiclist-param-offset": "Offset value to start fetching topics at.",
"apihelp-flow+view-topiclist-param-limit": "Number of topics to fetch.",
"apihelp-flow+view-topiclist-param-render": "Render the topics in HTML.",
"apihelp-flow+view-topiclist-example-1": "List topics on [[Talk:Sandbox]]",
"apihelp-flow-parsoid-utils-description": "Convert text between wikitext and HTML.",
"apihelp-flow-parsoid-utils-param-from": "Format to convert content from.",
"apihelp-flow-parsoid-utils-param-to": "Format to convert content to.",
"apihelp-flow-parsoid-utils-param-content": "Content to be converted.",
"apihelp-flow-parsoid-utils-param-title": "Title of the page. Cannot be used together with $1pageid.",
"apihelp-flow-parsoid-utils-param-pageid": "ID of the page. Cannot be used together with $1title.",
"apihelp-flow-parsoid-utils-example-1": "Convert wikitext '''lorem''' ''blah'' to HTML",
"apihelp-query+flowinfo-description": "Get basic Flow information about a page.",
"apihelp-query+flowinfo-example-1": "Fetch Flow information about [[Talk:Sandbox]], [[Main Page]], and [[Talk:Flow]]",
"apihelp-flow+undo-edit-header-description": "Retrieve information necessary to undo header edits.",
"apihelp-flow+undo-edit-header-param-startId": "Revision id to start undo at.",
"apihelp-flow+undo-edit-header-param-endId": "Revision id to end undo at.",
"apihelp-flow+undo-edit-header-example-1": "Fetch information about undoing a header edit at [[Talk:Sandbox]]",
"apihelp-flow+undo-edit-post-description": "Retrieve information necesary to undo post edit.",
"apihelp-flow+undo-edit-post-param-postId": "Post id to be undone.",
"apihelp-flow+undo-edit-post-param-startId": "Revision id to start undo at.",
"apihelp-flow+undo-edit-post-param-endId": "Revision id to end undo at.",
"apihelp-flow+undo-edit-post-example-1": "Fetch information about undoing a post edit in a specific topic.",
"apihelp-flow+undo-edit-topic-summary-description": "Retrieve information necessary to undo topic summary edits.",
"apihelp-flow+undo-edit-topic-summary-param-startId": "Revision id to start undo at.",
"apihelp-flow+undo-edit-topic-summary-param-endId": "Revision id to end undo at.",
"apihelp-flow+undo-edit-topic-summary-example-1": "Fetch information about undoing a topic summary edit in a specific topic",
"flow-edited": "Edited",
"flow-edited-by": "Edited by $1",
"flow-lqt-redirect-reason": "Redirecting retired LiquidThreads post to its converted Flow post",
"flow-talk-conversion-move-reason": "Conversion of wikitext talk to Flow from $1",
"flow-talk-conversion-archive-edit-reason": "Wikitext talk to Flow conversion",
"flow-previous-diff": "← Older edit",
"flow-next-diff": "Newer edit →",
"flow-undo": "undo",
"flow-undo-latest-revision": "Latest revision",
"flow-undo-your-text": "Your text",
"flow-undo-edit-header": "Editing the header",
"flow-undo-edit-topic-summary": "Editing the topic summary",
"flow-undo-edit-post": "Editing a post",
"flow-undo-edit-content": "The edit can be undone. Please check the comparison below to verify that this is what you want to do, and then save the changes below to finish undoing the edit.",
"flow-undo-edit-failure": "The edit could not be undone due to conflicting intermediate edits.",
"group-flow-bot": "Flow bots",
"group-flow-bot-member": "Flow bot",
"grouppage-flow-bot": "Project:Flow bots",
"flow-ve-mention-context-item-label": "Mention",
"flow-ve-mention-inspector-title": "Mention",
"flow-ve-mention-inspector-remove-label": "Remove",
"flow-ve-mention-tool-title": "Mention a user",
"flow-ve-mention-template": "ping",
"flow-ve-mention-inspector-invalid-user": "The username '$1' is not registered.",
"flow-wikitext-editor-help": "Wikitext $1.",
"flow-wikitext-editor-help-and-preview": "Wikitext $1 and you can $2 anytime.",
"flow-wikitext-editor-help-uses-wikitext": "[[mw:Help:Formatting|uses markup]]",
"flow-wikitext-editor-help-preview-the-result": "preview the result",
"flow-wikitext-switch-editor-tooltip": "Switch to VisualEditor",
"flow-ve-switch-editor-tool-title": "Switch to Wikitext editor"
}
diff --git a/includes/Import/Converter.php b/includes/Import/Converter.php
index b24bf2deec..bd5e019cc4 100644
--- a/includes/Import/Converter.php
+++ b/includes/Import/Converter.php
@@ -1,337 +1,343 @@
getId() ) {
throw new ImportException( 'User must have id' );
}
- $this->dbr = $dbr;
+ $this->dbw = $dbw;
$this->importer = $importer;
$this->logger = $logger;
$this->user = $user;
$this->strategy = $strategy;
$postprocessor = $strategy->getPostprocessor();
if ( $postprocessor !== null ) {
// @todo assert we cant cause duplicate postprocessors
$this->importer->addPostprocessor( $postprocessor );
}
// Force the importer to use our logger for consistent output.
$this->importer->setLogger( $logger );
}
/**
- * @param Traversable
$titles
+ * @param Traversable|array $titles
*/
public function convert( $titles ) {
/** @var Title $title */
foreach ( $titles as $title ) {
try {
$movedFrom = $this->getPageMovedFrom( $title );
if ( ! $this->isAllowed( $title, $movedFrom ) ) {
continue;
}
if ( $this->strategy->isConversionFinished( $title, $movedFrom ) ) {
continue;
}
$this->doConversion( $title, $movedFrom );
} catch ( \Exception $e ) {
MWExceptionHandler::logException( $e );
$this->logger->error( "Exception while importing: {$title}" );
$this->logger->error( (string)$e );
}
}
}
protected function isAllowed( Title $title, Title $movedFrom = null ) {
// Only make changes to wikitext pages
if ( $title->getContentModel() !== CONTENT_MODEL_WIKITEXT ) {
return false;
}
+ if ( !$title->exists() ) {
+ $this->logger->warning( "WARNING: The title '" . $title->getPrefixedDBkey() . "' is being skipped because it does not exist." );
+ return false;
+ }
+
// At some point we may want to handle these, but for now just
// let them be
if ( $title->isRedirect() ) {
return false;
}
// If we previously moved this page, continue the import
if ( $movedFrom !== null ) {
return true;
}
// Don't allow conversion of sub pages unless it is
// a talk page with matching subject page. For example
// we will convert User_talk:Foo/bar only if User:Foo/bar
// exists, and we will never convert User:Baz/bang.
if ( $title->isSubPage() && ( !$title->isTalkPage() || !$title->getSubjectPage()->exists() ) ) {
return false;
}
return true;
}
protected function doConversion( Title $title, Title $movedFrom = null ) {
if ( $movedFrom ) {
// If the page is moved but has not completed conversion that
// means the previous import failed to complete. Try again.
$archiveTitle = $title;
$title = $movedFrom;
$this->logger->info( "Page previously archived from $title to $archiveTitle" );
} else {
// The move needs to happen prior to the import because upon starting the
// import the top revision will be a flow-board revision.
$archiveTitle = $this->strategy->decideArchiveTitle( $title );
$this->logger->info( "Archiving page from $title to $archiveTitle" );
$this->movePage( $title, $archiveTitle );
+ wfWaitForSlaves(); // Wait for slaves to pick up the move
}
$source = $this->strategy->createImportSource( $archiveTitle );
if ( $this->importer->import( $source, $title, $this->strategy->getSourceStore() ) ) {
$this->createArchiveCleanupRevision( $title, $archiveTitle );
$this->logger->info( "Completed import to $title from $archiveTitle" );
} else {
$this->logger->error( "Failed to complete import to $title from $archiveTitle" );
}
}
/**
* Looks in the logging table to see if the provided title was last moved
* there by the user provided in the constructor. The provided user should
* be a system user for this task, as this assumes that user has never
* moved these pages outside the conversion process.
*
* This only considers the most recent move and not prior moves. This allows
* for edge cases such as starting an import, canceling it, and manually
* reverting the move by a normal user.
*
* @param Title $title
* @return Title|null
*/
protected function getPageMovedFrom( Title $title ) {
- $row = $this->dbr->selectRow(
+ $row = $this->dbw->selectRow(
array( 'logging', 'page' ),
array( 'log_namespace', 'log_title', 'log_user' ),
array(
'page_namespace' => $title->getNamespace(),
'page_title' => $title->getDBkey(),
'log_page = page_id',
'log_type' => 'move',
),
__METHOD__,
array(
'LIMIT' => 1,
'ORDER BY' => 'log_timestamp DESC'
)
);
// The page has never been moved
if ( !$row ) {
return null;
}
// The most recent move was not by our user
if ( $row->log_user != $this->user->getId() ) {
return null;
}
return Title::makeTitle( $row->log_namespace, $row->log_title );
}
/**
* Moves the source page to the destination. Does not leave behind a
* redirect, intending that flow will place a revision there for its new
* board.
*
* @param Title $from
* @param Title $to
* @throws ImportException on failed import
*/
protected function movePage( Title $from, Title $to ) {
$mp = new MovePage( $from, $to );
$valid = $mp->isValidMove();
if ( !$valid->isOK() ) {
$this->logger->error( $valid->getMessage()->text() );
throw new ImportException( "It is not valid to move {$from} to {$to}" );
}
// Note that this comment must match the regex in self::getPageMovedFrom
$status = $mp->move(
/* user */ $this->user,
/* reason */ $this->strategy->getMoveComment( $from, $to ),
/* create redirect */ false
);
if ( !$status->isGood() ) {
$this->logger->error( $status->getMessage()->text() );
throw new ImportException( "Failed moving {$from} to {$to}" );
}
}
/**
* Creates a new revision of the archived page that strips the LQT magic word
* and injects a template about the move. With the magic word stripped these pages
* will no longer contain the use-liquid-threads page property and will effectively
* no longer be lqt pages.
*
* @param Title $title Previous location of the page, before moving
* @param Title $archiveTitle Current location of the page, after moving
* @throws ImportException
*/
protected function createArchiveCleanupRevision( Title $title, Title $archiveTitle ) {
$page = WikiPage::factory( $archiveTitle );
$revision = $page->getRevision();
if ( $revision === null ) {
throw new ImportException( "Expected a revision at {$archiveTitle}" );
}
// Do not create revisions based on rev_deleted revisions.
$content = $revision->getContent( Revision::FOR_PUBLIC );
if ( !$content instanceof WikitextContent ) {
throw new ImportException( "Expected wikitext content at: {$archiveTitle}" );
}
$newContent = $this->strategy->createArchiveCleanupRevisionContent( $content, $title );
if ( $newContent === null ) {
return;
}
$status = $page->doEditContent(
$newContent,
$this->strategy->getCleanupComment( $title, $archiveTitle ),
EDIT_FORCE_BOT | EDIT_SUPPRESS_RC,
false,
$this->user
);
if ( !$status->isGood() ) {
$this->logger->error( $status->getMessage()->text() );
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/Importer.php b/includes/Import/Importer.php
index 1dcbc30213..20fa46f6a5 100644
--- a/includes/Import/Importer.php
+++ b/includes/Import/Importer.php
@@ -1,883 +1,892 @@
storage = $storage;
$this->workflowLoaderFactory = $workflowLoaderFactory;
$this->cache = $cache;
$this->dbFactory = $dbFactory;
$this->postprocessors = new ProcessorGroup;
$this->deferredQueue = $deferredQueue;
}
public function addPostprocessor( Postprocessor $proc ) {
$this->postprocessors->add( $proc );
}
+ /**
+ * Returns the ProcessorGroup (calling this triggers all the postprocessors
+ *
+ * @return Postprocessor
+ */
+ public function getPostprocessor() {
+ return $this->postprocessors;
+ }
+
/**
* @param LoggerInterface $logger
*/
public function setLogger( LoggerInterface $logger ) {
$this->logger = $logger;
}
/**
* @param bool $allowed When true allow usernames that do not exist on the wiki to be
* stored in the _ip field. *DO*NOT*USE* in any production setting, this is
* to allow for imports from production wiki api's to test machines for
* development purposes.
*/
public function setAllowUnknownUsernames( $allowed ) {
$this->allowUnknownUsernames = (bool)$allowed;
}
/**
* Imports topics from a data source to a given page.
*
* @param IImportSource $source
* @param Title $targetPage
* @param ImportSourceStore $sourceStore
* @return bool True When the import completes with no failures
*/
public function import( IImportSource $source, Title $targetPage, ImportSourceStore $sourceStore ) {
$operation = new TalkpageImportOperation( $source );
return $operation->import( new PageImportState(
$this->workflowLoaderFactory
->createWorkflowLoader( $targetPage )
->getWorkflow(),
$this->storage,
$sourceStore,
$this->logger ?: new NullLogger,
$this->cache,
$this->dbFactory,
$this->postprocessors,
$this->deferredQueue,
$this->allowUnknownUsernames
) );
}
}
/**
* Modified version of UIDGenerator generates historical timestamped
* uid's for use when importing older data.
*
* DO NOT USE for normal UID generation, this is likely to run into
* id collisions.
*
* The import process needs to identify collision failures reported by
* the database and re-try importing that item with another generated
* uid.
*/
class HistoricalUIDGenerator extends UIDGenerator {
public static function historicalTimestampedUID88( $timestamp, $base = 10 ) {
static $counter = false;
if ( $counter === false ) {
$counter = mt_rand( 0, 256 );
}
$time = array(
// seconds
wfTimestamp( TS_UNIX, $timestamp ),
// milliseconds
mt_rand( 0, 999 )
);
// The UIDGenerator is implemented very specifically to have
// a single instance, we have to reuse that instance.
$gen = self::singleton();
self::rotateNodeId( $gen );
$binaryUUID = $gen->getTimestampedID88(
array( $time, ++$counter % 1024 )
);
return wfBaseConvert( $binaryUUID, 2, $base );
}
/**
* Rotate the nodeId to a random one. The stable node is best for
* generating "now" uid's on a cluster of servers, but repeated
* creation of historical uid's with one or a smaller number of
* machines requires use of a random node id.
*
* @param UIDGenerator $gen
*/
protected static function rotateNodeId( UIDGenerator $gen ) {
// 4 bytes = 32 bits
$gen->nodeId32 = wfBaseConvert( MWCryptRand::generateHex( 8, true ), 16, 2, 32 );
// 6 bytes = 48 bits, used for 128bit uid's
//$gen->nodeId48 = wfBaseConvert( MWCryptRand::generateHex( 12, true ), 16, 2, 48 );
}
}
class PageImportState {
/**
* @var LoggerInterface
*/
public $logger;
/**
* @var Workflow
*/
public $boardWorkflow;
/**
* @var ManagerGroup
*/
protected $storage;
/**
* @var ReflectionProperty
*/
protected $workflowIdProperty;
/**
* @var ReflectionProperty[]
*/
protected $postIdProperty;
/**
* @var ReflectionProperty[]
*/
protected $revIdProperty;
/**
* @var ReflectionProperty[]
*/
protected $lastEditIdProperty;
/**
* @var bool
*/
protected $allowUnknownUsernames;
/**
* @var Postprocessor
*/
public $postprocessor;
/**
* @var SplQueue
*/
protected $deferredQueue;
public function __construct(
Workflow $boardWorkflow,
ManagerGroup $storage,
ImportSourceStore $sourceStore,
LoggerInterface $logger,
BufferedCache $cache,
DbFactory $dbFactory,
Postprocessor $postprocessor,
SplQueue $deferredQueue,
$allowUnknownUsernames = false
) {
$this->storage = $storage;;
$this->boardWorkflow = $boardWorkflow;
$this->sourceStore = $sourceStore;
$this->logger = $logger;
$this->cache = $cache;
$this->dbw = $dbFactory->getDB( DB_MASTER );
$this->postprocessor = $postprocessor;
$this->deferredQueue = $deferredQueue;
$this->allowUnknownUsernames = $allowUnknownUsernames;
// Get our workflow UUID property
$this->workflowIdProperty = new ReflectionProperty( 'Flow\\Model\\Workflow', 'id' );
$this->workflowIdProperty->setAccessible( true );
// Get our revision UUID properties
$this->postIdProperty = new ReflectionProperty( 'Flow\\Model\\PostRevision', 'postId' );
$this->postIdProperty->setAccessible( true );
$this->revIdProperty = new ReflectionProperty( 'Flow\\Model\\AbstractRevision', 'revId' );
$this->revIdProperty->setAccessible( true );
$this->lastEditIdProperty = new ReflectionProperty( 'Flow\\Model\\AbstractRevision', 'lastEditId' );
$this->lastEditIdProperty->setAccessible( true );
}
/**
* @param object|object[] $object
* @param array $metadata
*/
public function put( $object, array $metadata ) {
$metadata['imported'] = true;
if ( is_array( $object ) ) {
$this->storage->multiPut( $object, $metadata );
} else {
$this->storage->put( $object, $metadata );
}
}
/**
* Gets the given object from storage
*
* @param string $type Class name to retrieve
* @param UUID $id ID of the object to retrieve
* @return Object|false
*/
public function get( $type, UUID $id ) {
return $this->storage->get( $type, $id );
}
/**
* Gets the top revision of an item by ID
*
* @param string $type The type of the object to return (e.g. PostRevision).
* @param UUID $id The ID (e.g. post ID, topic ID, etc)
* @return object|false The top revision of the requested object, or false if not found.
*/
public function getTopRevision( $type, UUID $id ) {
$result = $this->storage->find(
$type,
array( 'rev_type_id' => $id ),
array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
);
if ( count( $result ) ) {
return reset( $result );
} else {
return false;
}
}
/**
* Creates a UUID object representing a given timestamp.
*
* @param string $timestamp The timestamp to represent, in a wfTimestamp compatible format.
* @return UUID
*/
public function getTimestampId( $timestamp ) {
return UUID::create( HistoricalUIDGenerator::historicalTimestampedUID88( $timestamp ) );
}
/**
* Update the id of the workflow to match the provided timestamp
*
* @param Workflow $workflow
* @param string $timestamp
*/
public function setWorkflowTimestamp( Workflow $workflow, $timestamp ) {
$uid = $this->getTimestampId( $timestamp );
$this->workflowIdProperty->setValue( $workflow, $uid );
}
/**
* @var AbstractRevision $summary
* @var string $timestamp
*/
public function setRevisionTimestamp( AbstractRevision $revision, $timestamp ) {
$uid = $this->getTimestampId( $timestamp );
$setRevId = true;
// We don't set the topic title postId as it was inherited from the workflow. We only set the
// postId for first revisions because further revisions inherit it from the parent which was
// set appropriately.
if ( $revision instanceof PostRevision && $revision->isFirstRevision() && !$revision->isTopicTitle() ) {
$this->postIdProperty->setValue( $revision, $uid );
}
if ( $setRevId ) {
if ( $revision->getRevisionId()->equals( $revision->getLastContentEditId() ) ) {
$this->lastEditIdProperty->setValue( $revision, $uid );
}
$this->revIdProperty->setValue( $revision, $uid );
}
}
/**
* Records an association between a created object and its source.
*
* @param UUID $objectId UUID representing the object that was created.
* @param IImportObject $object Output from getObjectKey
*/
public function recordAssociation( UUID $objectId, IImportObject $object ) {
$this->sourceStore->setAssociation( $objectId, $object->getObjectKey() );
}
/**
* Gets the imported ID for a given object, if any.
*
* @param IImportObject $object
* @return UUID|false
*/
public function getImportedId( IImportObject $object ) {
return $this->sourceStore->getImportedId( $object->getObjectKey() );
}
public function createUser( $name ) {
if ( IP::isIPAddress( $name ) ) {
return User::newFromName( $name, false );
}
$user = User::newFromName( $name );
if ( !$user ) {
throw new ImportException( 'Unable to create user: ' . $name );
}
if ( $user->getId() == 0 && !$this->allowUnknownUsernames ) {
throw new ImportException( 'User does not exist: ' . $name );
}
return $user;
}
public function begin() {
$this->flushDeferredQueue();
$this->dbw->begin();
$this->cache->begin();
}
public function commit() {
$this->dbw->commit();
$this->cache->commit();
$this->sourceStore->save();
$this->flushDeferredQueue();
}
public function rollback() {
$this->dbw->rollback();
$this->cache->rollback();
$this->sourceStore->rollback();
$this->clearDeferredQueue();
$this->postprocessor->importAborted();
}
protected function flushDeferredQueue() {
while ( !$this->deferredQueue->isEmpty() ) {
DeferredUpdates::addCallableUpdate( $this->deferredQueue->dequeue() );
}
DeferredUpdates::doUpdates();
}
protected function clearDeferredQueue() {
while ( !$this->deferredQueue->isEmpty() ) {
$this->deferredQueue->dequeue();
}
}
}
class TopicImportState {
/**
* @var PageImportState
*/
public $parent;
/**
* @var Workflow
*/
public $topicWorkflow;
/**
* @var PostRevision
*/
public $topicTitle;
/**
* @var string
*/
protected $lastModified;
public function __construct(
PageImportState $parent,
Workflow $topicWorkflow,
PostRevision $topicTitle
) {
$this->parent = $parent;
$this->topicWorkflow = $topicWorkflow;
$this->topicTitle = $topicTitle;
$this->workflowModifiedProperty = new ReflectionProperty( 'Flow\\Model\\Workflow', 'lastModified' );
$this->workflowModifiedProperty->setAccessible( true );
$this->lastModified = '';
$this->recordModificationTime( $topicWorkflow->getId() );
}
public function getMetadata() {
return array(
'workflow' => $this->topicWorkflow,
'board-workflow' => $this->parent->boardWorkflow,
'topic-title' => $this->topicTitle,
);
}
/**
* Notify the state about a modification action at a given time.
*
* @param UUID $uuid UUID of the modification revision.
*/
public function recordModificationTime( UUID $uuid ) {
$timestamp = $uuid->getTimestamp();
$timestamp = wfTimestamp( TS_MW, $timestamp );
if ( $timestamp > $this->lastModified ) {
$this->lastModified = $timestamp;
}
}
/**
* Saves the last modified timestamp based on calls to recordModificationTime
* XXX: Kind of icky; reaching through the parent and doing a second put().
*/
public function commitLastModified() {
$this->workflowModifiedProperty->setValue(
$this->topicWorkflow,
$this->lastModified
);
$this->parent->put( $this->topicWorkflow, $this->getMetadata() );
}
}
class TalkpageImportOperation {
/**
* @var IImportSource
*/
protected $importSource;
/**
* @param IImportSource $source
*/
public function __construct( IImportSource $source ) {
$this->importSource = $source;
}
/**
* @param PageImportState $state
* @return bool True if import completed successfully
* @throws ImportSourceStoreException
* @throws \Exception
*/
public function import( PageImportState $state ) {
$state->logger->info( 'Importing to ' . $state->boardWorkflow->getArticleTitle()->getPrefixedText() );
if ( $state->boardWorkflow->isNew() ) {
$state->put( $state->boardWorkflow, array() );
}
$imported = $failed = 0;
$header = $this->importSource->getHeader();
if ( $header ) {
try {
$state->begin();
$this->importHeader( $state, $header );
$state->commit();
$state->postprocessor->afterHeaderImported( $state, $header );
$imported++;
} catch ( ImportSourceStoreException $e ) {
// errors from the source store are more serious and should
// not just be logged and swallowed. This may indicate that
// we are not properly recording progress.
$state->rollback();
throw $e;
} catch ( \Exception $e ) {
$state->rollback();
\MWExceptionHandler::logException( $e );
$state->logger->error( 'Failed importing header: ' . $header->getObjectKey() );
$state->logger->error( (string)$e );
$failed++;
}
}
foreach( $this->importSource->getTopics() as $topic ) {
try {
// @todo this may be too large of a chunk for one commit, unsure
$state->begin();
$topicState = $this->getTopicState( $state, $topic );
$this->importTopic( $topicState, $topic );
$state->commit();
$state->postprocessor->afterTopicImported( $topicState, $topic );
$imported++;
} catch ( ImportSourceStoreException $e ) {
// errors from the source store are more serious and shuld
// not juts be logged and swallowed. This may indicate that
// we are not properly recording progress.
$state->rollback();
throw $e;
} catch ( \Exception $e ) {
$state->rollback();
\MWExceptionHandler::logException( $e );
$state->logger->error( 'Failed importing topic: ' . $topic->getObjectKey() );
$state->logger->error( (string)$e );
$failed++;
}
}
$state->logger->info( "Imported $imported items, failed $failed" );
return $failed === 0;
}
/**
* @param PageImportState $pageState
* @param IImportHeader $importHeader
*/
public function importHeader( PageImportState $pageState, IImportHeader $importHeader ) {
$pageState->logger->info( 'Importing header' );
if ( ! $importHeader->getRevisions()->valid() ) {
$pageState->logger->info( 'no revisions located for header' );
// No revisions
return;
}
$existingId = $pageState->getImportedId( $importHeader );
if ( $existingId && $pageState->getTopRevision( 'Header', $existingId ) ) {
$pageState->logger->info( 'header previously imported' );
return;
}
$revisions = $this->importObjectWithHistory(
$importHeader,
function( IObjectRevision $rev ) use ( $pageState ) {
return Header::create(
$pageState->boardWorkflow,
$pageState->createUser( $rev->getAuthor() ),
$rev->getText(),
'wikitext',
'create-header'
);
},
'edit-header',
$pageState,
$pageState->boardWorkflow->getArticleTitle()
);
$pageState->put( $revisions, array(
'workflow' => $pageState->boardWorkflow,
) );
$pageState->recordAssociation(
reset( $revisions )->getCollectionId(),
$importHeader
);
$pageState->logger->info( 'Imported ' . count( $revisions ) . ' revisions for header' );
}
/**
* @param TopicImportState $topicState
* @param IImportTopic $importTopic
*/
public function importTopic( TopicImportState $topicState, IImportTopic $importTopic ) {
$summary = $importTopic->getTopicSummary();
if ( $summary ) {
$this->importSummary( $topicState, $summary );
}
foreach ( $importTopic->getReplies() as $post ) {
$this->importPost( $topicState, $post, $topicState->topicTitle );
}
$topicState->commitLastModified();
}
/**
* @param PageImportState $state
* @param IImportTopic $importTopic
* @return TopicImportState
*/
protected function getTopicState( PageImportState $state, IImportTopic $importTopic ) {
// Check if it's already been imported
$topicState = $this->getExistingTopicState( $state, $importTopic );
if ( $topicState ) {
$state->logger->info( 'Continuing import to ' . $topicState->topicWorkflow->getArticleTitle()->getPrefixedText() );
return $topicState;
} else {
return $this->createTopicState( $state, $importTopic );
}
}
protected function getFirstRevision( IRevisionableObject $obj ) {
$iterator = $obj->getRevisions();
$iterator->rewind();
return $iterator->current();
}
/**
* @param PageImportState $state
* @param IImportTopic $importTopic
* @return TopicImportState
*/
protected function createTopicState( PageImportState $state, IImportTopic $importTopic ) {
$state->logger->info( 'Importing new topic' );
$topicWorkflow = Workflow::create(
'topic',
$state->boardWorkflow->getArticleTitle()
);
$state->setWorkflowTimestamp(
$topicWorkflow,
$this->getFirstRevision( $importTopic )->getTimestamp()
);
$topicListEntry = TopicListEntry::create(
$state->boardWorkflow,
$topicWorkflow
);
$titleRevisions = $this->importObjectWithHistory(
$importTopic,
function( IObjectRevision $rev ) use ( $state, $topicWorkflow ) {
return PostRevision::create(
$topicWorkflow,
$state->createUser( $rev->getAuthor() ),
$rev->getText(),
'wikitext'
);
},
'edit-title',
$state,
$topicWorkflow->getArticleTitle()
);
$topicState = new TopicImportState( $state, $topicWorkflow, end( $titleRevisions ) );
$topicMetadata = $topicState->getMetadata();
// TLE must be first, otherwise you get an error importing the Topic Title
// Flow/includes/Data/Index/BoardHistoryIndex.php:
// No topic list contains topic XXX, called for revision YYY
$state->put( $topicListEntry, $topicMetadata );
// Topic title must be second, because inserting topicWorkflow requires
// the topic title to already be in place
$state->put( $titleRevisions, $topicMetadata );
$state->put( $topicWorkflow, $topicMetadata );
$state->recordAssociation( $topicWorkflow->getId(), $importTopic );
$state->logger->info( 'Finished importing topic title with ' . count( $titleRevisions ) . ' revisions' );
return $topicState;
}
/**
* @param PageImportState $state
* @param IImportTopic $importTopic
* @return TopicImportState|null
*/
protected function getExistingTopicState( PageImportState $state, IImportTopic $importTopic ) {
$topicId = $state->getImportedId( $importTopic );
if ( $topicId ) {
$topicWorkflow = $state->get( 'Workflow', $topicId );
$topicTitle = $state->getTopRevision( 'PostRevision', $topicId );
if ( $topicWorkflow instanceof Workflow && $topicTitle instanceof PostRevision ) {
return new TopicImportState( $state, $topicWorkflow, $topicTitle );
}
}
return null;
}
/**
* @param TopicImportState $state
* @param IImportSummary $importSummary
*/
public function importSummary( TopicImportState $state, IImportSummary $importSummary ) {
$state->parent->logger->info( "Importing summary" );
$existingId = $state->parent->getImportedId( $importSummary );
if ( $existingId ) {
$summary = $state->parent->getTopRevision( 'PostSummary', $existingId );
if ( $summary ) {
$state->recordModificationTime( $summary->getRevisionId() );
$state->parent->logger->info( "Summary previously imported" );
return;
}
}
$revisions = $this->importObjectWithHistory(
$importSummary,
function( IObjectRevision $rev ) use ( $state ) {
return PostSummary::create(
$state->topicWorkflow->getArticleTitle(),
$state->topicTitle,
$state->parent->createUser( $rev->getAuthor() ),
$rev->getText(),
'wikitext',
'create-topic-summary'
);
},
'edit-topic-summary',
$state->parent,
$state->topicWorkflow->getArticleTitle()
);
$metadata = array(
'workflow' => $state->topicWorkflow,
);
$state->parent->put( $revisions, $metadata );
$state->parent->recordAssociation(
reset( $revisions )->getCollectionId(), // Summary ID
$importSummary
);
$state->recordModificationTime( end( $revisions )->getRevisionId() );
$state->parent->logger->info( "Finished importing summary with " . count( $revisions ) . " revisions" );
}
/**
* @param TopicImportState $state
* @param IImportPost $post
* @param PostRevision $replyTo
* @param string $logPrefix
*/
public function importPost(
TopicImportState $state,
IImportPost $post,
PostRevision $replyTo,
$logPrefix = ' '
) {
$state->parent->logger->info( $logPrefix . "Importing post" );
$postId = $state->parent->getImportedId( $post );
$topRevision = false;
if ( $postId ) {
$topRevision = $state->parent->getTopRevision( 'PostRevision', $postId );
}
if ( $topRevision ) {
$state->parent->logger->info( $logPrefix . "Post previously imported" );
} else {
$replyRevisions = $this->importObjectWithHistory(
$post,
function( IObjectRevision $rev ) use ( $replyTo, $state ) {
return $replyTo->reply(
$state->topicWorkflow,
$state->parent->createUser( $rev->getAuthor() ),
$rev->getText(),
'wikitext'
);
},
'edit-post',
$state->parent,
$state->topicWorkflow->getArticleTitle()
);
$topRevision = end( $replyRevisions );
$metadata = array(
'workflow' => $state->topicWorkflow,
'board-workflow' => $state->parent->boardWorkflow,
'topic-title' => $state->topicTitle,
'reply-to' => $replyTo,
);
$state->parent->put( $replyRevisions, $metadata );
$state->parent->recordAssociation(
$topRevision->getPostId(),
$post
);
$state->parent->logger->info( $logPrefix . "Finished importing post with " . count( $replyRevisions ) . " revisions" );
$state->parent->postprocessor->afterPostImported( $state, $post, $topRevision->getPostId() );
}
$state->recordModificationTime( $topRevision->getRevisionId() );
foreach ( $post->getReplies() as $subReply ) {
$this->importPost( $state, $subReply, $topRevision, $logPrefix . ' ' );
}
}
/**
* Imports an object with all its revisions
*
* @param IRevisionableObject $object Object to import.
* @param callable $importFirstRevision Function which, given the appropriate import revision, creates the Flow revision.
* @param string $editChangeType The Flow change type (from FlowActions.php) for each new operation.
* @param PageImportState $state State of the import operation.
* @param Title $title Title content is rendered against
* @return AbstractRevision[] Objects to insert into the database.
* @throws ImportException
*/
public function importObjectWithHistory(
IRevisionableObject $object,
$importFirstRevision,
$editChangeType,
PageImportState $state,
Title $title
) {
$insertObjects = array();
$revisions = $object->getRevisions();
$revisions->rewind();
if ( ! $revisions->valid() ) {
throw new ImportException( "Attempted to import empty history" );
}
$importRevision = $revisions->current();
/** @var AbstractRevision $lastRevision */
$insertObjects[] = $lastRevision = $importFirstRevision( $importRevision );
$lastTimestamp = $importRevision->getTimestamp();
$state->setRevisionTimestamp( $lastRevision, $lastTimestamp );
$state->recordAssociation( $lastRevision->getRevisionId(), $importRevision );
$state->recordAssociation( $lastRevision->getCollectionId(), $importRevision );
$revisions->next();
while( $revisions->valid() ) {
$importRevision = $revisions->current();
$insertObjects[] = $lastRevision =
$lastRevision->newNextRevision(
$state->createUser( $importRevision->getAuthor() ),
$importRevision->getText(),
'wikitext',
$editChangeType,
$title
);
if ( $importRevision->getTimestamp() < $lastTimestamp ) {
throw new ImportException( "Revision listing is not sorted from oldest to newest" );
}
$lastTimestamp = $importRevision->getTimestamp();
$state->setRevisionTimestamp( $lastRevision, $lastTimestamp );
$state->recordAssociation( $lastRevision->getRevisionId(), $importRevision );
$revisions->next();
}
return $insertObjects;
}
}
diff --git a/includes/Import/LiquidThreadsApi/Objects.php b/includes/Import/LiquidThreadsApi/Objects.php
index 7bd7dc9731..82f7b1aaa5 100644
--- a/includes/Import/LiquidThreadsApi/Objects.php
+++ b/includes/Import/LiquidThreadsApi/Objects.php
@@ -1,482 +1,483 @@
importSource = $source;
$this->pageId = $pageId;
}
public function getRevisions() {
$pageData = $this->importSource->getPageData( $this->pageId );
// filter revisions without content (deleted)
foreach ( $pageData['revisions'] as $key => $value ) {
if ( isset( $value['texthidden'] ) ) {
unset( $pageData['revisions'][$key] );
}
}
// the iterators expect this to be a 0 indexed list
$pageData['revisions'] = array_values( $pageData['revisions'] );
$scriptUser = $this->importSource->getScriptUser();
return new RevisionIterator( $pageData, $this, function( $data, $parent ) use ( $scriptUser ) {
return new ImportRevision( $data, $parent, $scriptUser );
} );
}
}
class ImportPost extends PageRevisionedObject implements IImportPost {
/**
* @var array
*/
protected $apiResponse;
/**
* @param ImportSource $source
* @param array $apiResponse
*/
public function __construct( ImportSource $source, array $apiResponse ) {
parent::__construct( $source, $apiResponse['rootid'] );
$this->apiResponse = $apiResponse;
}
/**
* @return string
*/
public function getAuthor() {
return $this->apiResponse['author']['name'];
}
/**
* @return string|false
*/
public function getCreatedTimestamp() {
return wfTimestamp( TS_MW, $this->apiResponse['created'] );
}
/**
* @return string|false
*/
public function getModifiedTimestamp() {
return wfTimestamp( TS_MW, $this->apiResponse['modified'] );
}
/**
* @return string
*/
public function getText() {
$pageData = $this->importSource->getPageData( $this->apiResponse['rootid'] );
$revision = $pageData['revisions'][0];
if ( defined( 'ApiResult::META_CONTENT' ) ) {
$contentKey = isset( $revision[ApiResult::META_CONTENT] )
? $revision[ApiResult::META_CONTENT]
: '*';
} else {
$contentKey = '*';
}
return $revision[$contentKey];
}
public function getTitle() {
$pageData = $this->importSource->getPageData( $this->apiResponse['rootid'] );
return Title::newFromText( $pageData['title'] );
}
/**
* @return Iterator
*/
public function getReplies() {
return new ReplyIterator( $this );
}
/**
* @return array
*/
public function getApiResponse() {
return $this->apiResponse;
}
/**
* @return ImportSource
*/
public function getSource() {
return $this->importSource;
}
public function getObjectKey() {
return $this->importSource->getObjectKey( 'thread_id', $this->apiResponse['id'] );
}
}
/**
* This is a bit of a weird model, acting as a revision of itself.
*/
class ImportTopic extends ImportPost implements IImportTopic, IObjectRevision {
/**
* @return string
*/
public function getText() {
return $this->apiResponse['subject'];
}
public function getAuthor() {
return $this->apiResponse['author']['name'];
}
public function getRevisions() {
// we only have access to a single revision of the topic
return new ArrayIterator( array( $this ) );
}
public function getReplies() {
$topPost = new ImportPost( $this->importSource, $this->apiResponse );
return new ArrayIterator( array( $topPost ) );
}
public function getTimestamp() {
return wfTimestamp( TS_MW, $this->apiResponse['created'] );
}
/**
* @return IImportSummary|null
*/
public function getTopicSummary() {
$id = $this->getSummaryId();
if ( $id > 0 ) {
$data = $this->importSource->getPageData( $id );
if ( isset( $data['revisions'][0] ) ) {
return new ImportSummary( $data, $this->importSource );
} else {
return null;
}
} else {
return null;
}
}
/**
* @return integer
*/
protected function getSummaryId() {
return $this->apiResponse['summaryid'];
}
/**
* This needs to have a different value than the same apiResponse in an ImportPost.
* The ImportPost version refers to the first response to the topic.
*/
public function getObjectKey() {
return 'topic' . $this->importSource->getObjectKey( 'thread_id', $this->apiResponse['id'] );
}
public function getLogType() {
return "lqt-to-flow-topic";
}
public function getLogParameters() {
return array(
'lqt_thread_id' => $this->apiResponse['id'],
'lqt_orig_title' => $this->getTitle()->getPrefixedText(),
'lqt_subject' => $this->getText(),
);
}
public function getLqtThreadId() {
return $this->apiResponse['id'];
}
}
class ImportSummary extends PageRevisionedObject implements IImportSummary {
/** @var ImportSource **/
protected $source;
/**
* @param array $apiResponse
* @param ImportSource $source
* @throws ImportException
*/
public function __construct( array $apiResponse, ImportSource $source ) {
parent::__construct( $source, $apiResponse['pageid'] );
}
public function getObjectKey() {
return $this->importSource->getObjectKey( 'summary_id', $this->pageId );
}
}
class ImportRevision implements IObjectRevision {
/** @var IImportObject **/
protected $parentObject;
/** @var array **/
protected $apiResponse;
/**
* @var User Account used when the imported revision is by a supressed user
*/
protected $scriptUser;
/**
* Creates an ImportRevision based on a MW page revision
*
* @param array $apiResponse An element from api.query.revisions
* @param IImportObject $parentObject
* @param User $user Account used when the imported revision is by a suppressed user
*/
function __construct( array $apiResponse, IImportObject $parentObject, User $scriptUser ) {
$this->apiResponse = $apiResponse;
$this->parent = $parentObject;
$this->scriptUser = $scriptUser;
}
/**
* @return string
*/
public function getText() {
if ( defined( 'ApiResult::META_CONTENT' ) ) {
$contentKey = isset( $this->apiResponse[ApiResult::META_CONTENT] )
? $this->apiResponse[ApiResult::META_CONTENT]
: '*';
} else {
$contentKey = '*';
}
if ( !isset( $this->apiResponse[$contentKey] ) ) {
$encoded = json_encode( $this->apiResponse );
throw new ImportException( "Specified content key ($contentKey) not available: $encoded" );
}
$content = $this->apiResponse[$contentKey];
if ( isset( $this->apiResponse['userhidden'] ) ) {
$template = wfMessage( 'flow-importer-lqt-suppressed-user-template' )->inContentLanguage()->plain();
$content .= "\n\n{{{$template}}}";
}
return $content;
}
public function getTimestamp() {
return wfTimestamp( TS_MW, $this->apiResponse['timestamp'] );
}
public function getAuthor() {
if ( isset( $this->apiResponse['userhidden'] ) ) {
return $this->scriptUser->getName();
} else {
return $this->apiResponse['user'];
}
}
public function getObjectKey() {
return $this->parent->getObjectKey() . ':rev:' . $this->apiResponse['revid'];
}
}
// The Moved* series of topics handle the LQT move stubs. They need to
// have their revision content rewriten from #REDIRECT to a template that
// has visible output like lqt generated per-request.
class MovedImportTopic extends ImportTopic {
public function getReplies() {
$topPost = new MovedImportPost( $this->importSource, $this->apiResponse );
return new ArrayIterator( array( $topPost ) );
}
}
class MovedImportPost extends ImportPost {
public function getRevisions() {
- $factory = function( $data, $parent ) {
- return new MovedImportRevision( $data, $parent );
+ $scriptUser = $this->importSource->getScriptUser();
+ $factory = function ( $data, $parent ) use ( $scriptUser ) {
+ return new MovedImportRevision( $data, $parent, $scriptUser );
};
$pageData = $this->importSource->getPageData( $this->pageId );
return new RevisionIterator( $pageData, $this, $factory );
}
}
class MovedImportRevision extends ImportRevision {
/**
* Rewrites the '#REDIRECT [[...]]' of an autogenerated lqt moved
* thread stub into a template. While we don't re-write the link
* here, after importing the referenced thread LqtRedirector will
* make that Thread page a redirect to the Flow topic, essentially
* making these links still work.
*/
public function getText() {
$text = parent::getText();
$content = \ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT );
$target = $content->getRedirectTarget();
if ( !$target ) {
throw new ImportException( "Could not detect redirect within: $text" );
}
// To get the new talk page that this belongs to we would need to query the api
// for the new topic, for now not bothering.
$template = wfMessage( 'flow-importer-lqt-moved-thread-template' )->inContentLanguage()->plain();
$arguments = implode( '|', array(
'author=' . parent::getAuthor(),
'date=' . MWTimestamp::getInstance( $this->apiResponse['timestamp'] )->timestamp->format( 'Y-m-d' ),
'title=' . $target->getPrefixedText(),
) );
return "{{{$template}|$arguments}}";
}
}
// Represents a revision the script makes on its own behalf, using a script user
class ScriptedImportRevision implements IObjectRevision {
/** @var IImportObject **/
protected $parentObject;
/** @var User */
protected $destinationScriptUser;
/** @var string */
protected $revisionText;
/** @var string */
protected $timestamp;
/**
* Creates a ScriptedImportRevision with the current timestamp, given a script user
* and arbitrary text.
*
* @param IImportObject $parentObject Object this is a revision of
* @param User $destinationScriptUser User that performed this scripted edit
* @param string $revisionText Text of revision
*/
function __construct( IImportObject $parentObject, User $destinationScriptUser, $revisionText ) {
$this->parent = $parentObject;
$this->destinationScriptUser = $destinationScriptUser;
$this->revisionText = $revisionText;
$this->timestamp = wfTimestampNow();
}
public function getText() {
return $this->revisionText;
}
public function getTimestamp() {
return $this->timestamp;
}
public function getAuthor() {
return $this->destinationScriptUser->getName();
}
// XXX: This is called but never used, but if it were, including getText and getAuthor in
// the key might not be desirable, because we don't necessarily want to re-import
// the revision when these change.
public function getObjectKey() {
return $this->parent->getObjectKey() . ':rev:scripted:' . md5( $this->getText() . $this->getAuthor() );
}
}
class ImportHeader extends PageRevisionedObject implements IImportHeader {
/** @var ApiBackend **/
protected $api;
/** @var string **/
protected $title;
/** @var array **/
protected $pageData;
/** @var ImportSource **/
protected $source;
public function __construct( ApiBackend $api, ImportSource $source, $title ) {
$this->api = $api;
$this->title = $title;
$this->source = $source;
$this->pageData = null;
}
public function getRevisions() {
if ( $this->pageData === null ) {
// Previous revisions of the header are preserved in the underlying wikitext
// page history. Only the top revision is imported.
$response = $this->api->retrieveTopRevisionByTitle( array( $this->title ) );
$this->pageData = reset( $response );
}
$revisions = array();
if ( isset( $this->pageData['revisions'] ) && count( $this->pageData['revisions'] ) > 0 ) {
$lastLqtRevision = new ImportRevision(
end( $this->pageData['revisions'] ),
$this,
$this->source->getScriptUser()
);
$titleObject = Title::newFromText( $this->title );
$cleanupRevision = $this->createHeaderCleanupRevision( $lastLqtRevision, $titleObject );
$revisions = array( $lastLqtRevision, $cleanupRevision );
}
return new ArrayIterator( $revisions );
}
/**
* @param IObjectRevision $lastRevision last imported header revision
* @param Title $archiveTitle archive page title associated with header
* @return IObjectRevision generated revision for cleanup edit
*/
protected function createHeaderCleanupRevision( IObjectRevision $lastRevision, Title $archiveTitle ) {
$wikitextForLastRevision = $lastRevision->getText();
// This is will remove all instances, without attempting to check if it's in
// nowiki, etc. It also ignores case and spaces in places where it doesn't
// matter.
$newWikitext = preg_replace(
'/{{\s*#useliquidthreads:\s*1\s*}}/i',
'',
$wikitextForLastRevision
);
$templateName = wfMessage( 'flow-importer-lqt-converted-template' )->inContentLanguage()->plain();
$arguments = implode( '|', array(
'archive=' . $archiveTitle->getPrefixedText(),
'date=' . MWTimestamp::getInstance()->timestamp->format( 'Y-m-d' ),
) );
$newWikitext .= "\n\n{{{$templateName}|$arguments}}";
$cleanupRevision = new ScriptedImportRevision(
$this,
$this->source->getScriptUser(),
$newWikitext,
$lastRevision->getTimestamp()
);
return $cleanupRevision;
}
public function getObjectKey() {
return $this->source->getObjectKey( 'header_for', $this->title );
}
}
diff --git a/includes/Import/Postprocessor/LqtRedirector.php b/includes/Import/Postprocessor/LqtRedirector.php
index 39f521b9c4..1517b9e792 100644
--- a/includes/Import/Postprocessor/LqtRedirector.php
+++ b/includes/Import/Postprocessor/LqtRedirector.php
@@ -1,85 +1,84 @@
urlGenerator = $urlGenerator;
$this->redirectsToDo = array();
$this->user = $user;
}
public function afterHeaderImported( PageImportState $state, IImportHeader $header ) {
// not a thing to do, yet
}
-
public function afterPostImported( TopicImportState $state, IImportPost $post, UUID $newPostId ) {
if ( $post instanceof ImportPost /* LQT */ ) {
$this->redirectsToDo[] = array(
$post->getTitle(),
$state->topicWorkflow->getId(),
$newPostId
);
}
}
public function afterTopicImported( TopicImportState $state, IImportTopic $topic ) {
if ( !$topic instanceof ImportTopic /* LQT */ ) {
return;
}
$this->doRedirect(
$topic->getTitle(),
$state->topicWorkflow->getId()
);
foreach( $this->redirectsToDo as $args ) {
call_user_func_array( array( $this, 'doRedirect' ), $args );
}
$this->redirectsToDo = array();
}
public function importAborted() {
$this->redirectsToDo = array();
}
protected function doRedirect( Title $fromTitle, UUID $toTopic, UUID $toPost = null ) {
if ( $toPost ) {
$redirectAnchor = $this->urlGenerator->postLink( null, $toTopic, $toPost );
} else {
$redirectAnchor = $this->urlGenerator->topicLink( null, $toTopic );
}
$redirectTarget = $redirectAnchor->resolveTitle();
$newContent = new WikiTextContent( "#REDIRECT [[".$redirectTarget->getFullText()."]]" );
$page = WikiPage::factory( $fromTitle );
$summary = wfMessage( 'flow-lqt-redirect-reason' )->plain();
$page->doEditContent( $newContent, $summary, EDIT_FORCE_BOT, false, $this->user );
WatchedItem::duplicateEntries( $fromTitle, $redirectTarget );
}
}
diff --git a/includes/Import/Postprocessor/Postprocessor.php b/includes/Import/Postprocessor/Postprocessor.php
index de9be8eb04..95db70bee9 100644
--- a/includes/Import/Postprocessor/Postprocessor.php
+++ b/includes/Import/Postprocessor/Postprocessor.php
@@ -1,51 +1,53 @@
**/
protected $processors;
public function __construct( ) {
$this->processors = array();
}
public function add( Postprocessor $proc ) {
$this->processors[] = $proc;
}
public function afterHeaderImported( PageImportState $state, IImportHeader $header ) {
$this->call( __FUNCTION__, func_get_args() );
}
+
public function afterTopicImported( TopicImportState $state, IImportTopic $topic ) {
$this->call( __FUNCTION__, func_get_args() );
}
public function afterPostImported( TopicImportState $state, IImportPost $post, UUID $newPostId ) {
$this->call( __FUNCTION__, func_get_args() );
}
public function importAborted() {
$this->call( __FUNCTION__, func_get_args() );
}
protected function call( $name, $args ) {
foreach( $this->processors as $proc ) {
call_user_func_array( array( $proc, $name ), $args );
}
}
}
diff --git a/maintenance/convertLqt.php b/maintenance/convertLqt.php
index a4b7521964..e2e69abd61 100644
--- a/maintenance/convertLqt.php
+++ b/maintenance/convertLqt.php
@@ -1,73 +1,68 @@
mDescription = "Converts LiquidThreads data to Flow data";
$this->addOption( 'logfile', 'File to read and store associations between imported items and their sources. This is required for the import to be idempotent.', true, true );
- $this->addOption( 'verbose', 'Report on import progress to stdout' );
$this->addOption( 'debug', 'Include debug information with progress report' );
$this->addOption( 'startId', 'Page id to start importing at (inclusive)' );
$this->addOption( 'stopId', 'Page id to stop importing at (exclusive)' );
}
public function execute() {
- if ( $this->getOption( 'verbose' ) ) {
- $logger = new MaintenanceDebugLogger( $this );
- if ( $this->getOption( 'debug' ) ) {
- $logger->setMaximumLevel( LogLevel::DEBUG );
- } else {
- $logger->setMaximumLevel( LogLevel::INFO );
- }
+ $logger = new MaintenanceDebugLogger( $this );
+ if ( $this->getOption( 'debug' ) ) {
+ $logger->setMaximumLevel( LogLevel::DEBUG );
} else {
- $logger = new NullLogger;
+ $logger->setMaximumLevel( LogLevel::INFO );
}
+
$importer = Flow\Container::get( 'importer' );
$talkpageManagerUser = FlowHooks::getOccupationController()->getTalkpageManager();
+ $dbw = wfGetDB( DB_MASTER );
$strategy = new ConversionStrategy(
- wfGetDB( DB_MASTER ),
+ $dbw,
new FileImportSourceStore( $this->getOption( 'logfile' ) ),
new LocalApiBackend( $talkpageManagerUser ),
Container::get( 'url_generator' ),
$talkpageManagerUser,
- Container::get( 'controller.notifications' )
+ Container::get( 'controller.notification' )
);
- $dbr = wfGetDB( DB_SLAVE );
$converter = new \Flow\Import\Converter(
- $dbr,
+ $dbw,
$importer,
$logger,
$talkpageManagerUser,
$strategy
);
$startId = $this->getOption( 'startId' );
$stopId = $this->getOption( 'stopId' );
$logger->info( "Starting full wiki LQT conversion" );
- $titles = new PagesWithPropertyIterator( $dbr, 'use-liquid-threads', $startId, $stopId );
+ $titles = new PagesWithPropertyIterator( $dbw, 'use-liquid-threads', $startId, $stopId );
$converter->convert( $titles );
}
}
$maintClass = "ConvertLqt";
require_once ( RUN_MAINTENANCE_IF_MAIN );
diff --git a/maintenance/convertLqtPage.php b/maintenance/convertLqtPage.php
deleted file mode 100644
index a6bcf02fcd..0000000000
--- a/maintenance/convertLqtPage.php
+++ /dev/null
@@ -1,113 +0,0 @@
-mDescription = "Converts LiquidThreads data to Flow data";
- $this->addArg( 'dstpage', 'Page name of the local page to import to', true );
- $this->addOption( 'srcpage', 'Page name of the remote page to import from. If not specified defaults to dstpage', false, true );
- $this->addOption( 'remoteapi', 'Remote API URL to read from', false, true );
- $this->addOption( 'cacheremoteapidir', 'Cache remote api calls to the specified directory', false, true );
- $this->addOption( 'logfile', 'File to read and store associations between imported items and their sources', false, true );
- $this->addOption( 'verbose', 'Report on import progress to stdout' );
- $this->addOption( 'debug', 'Include debug information to progress report' );
- $this->addOption( 'allowunknownusernames', 'Allow import of usernames that do not exist on this wiki. DO NOT USE IN PRODUCTION. This simplifies testing imports of production data to a test wiki' );
- $this->addOption( 'redirect', 'Add redirects from LQT posts to their Flow equivalents and update watchlists' );
- $this->addOption( 'convertnotifications', 'Convert LQT notifications into Echo notifications' );
- }
-
- public function execute() {
- $dstPageName = $srcPageName = $this->getArg( 0 );
-
- if ( $this->hasOption( 'srcpage' ) ) {
- $srcPageName = $this->getOption( 'srcpage' );
- }
-
- if ( $this->hasOption( 'remoteapi' ) ) {
- if ( $this->hasOption( 'cacheremoteapidir' ) ) {
- $cacheDir = $this->getOption( 'cacheremoteapidir' );
- if ( !is_dir( $cacheDir ) ) {
- if ( !mkdir( $cacheDir ) ) {
- throw new Flow\Exception\FlowException( 'Provided dir for caching remote api calls is not creatable.' );
- }
- }
- if ( !is_writable( $cacheDir ) ) {
- throw new Flow\Exception\FlowException( 'Provided dir for caching remote api calls is not writable.' );
- }
- } else {
- $cacheDir = null;
- }
- $api = new RemoteApiBackend( $this->getOption( 'remoteapi' ), $cacheDir );
- } else {
- $api = new LocalApiBackend;
- }
-
- $importer = Flow\Container::get( 'importer' );
- if ( $this->getOption( 'allowunknownusernames' ) ) {
- $importer->setAllowUnknownUsernames( true );
- }
- $source = new LiquidThreadsApiImportSource(
- $api,
- $srcPageName,
- \FlowHooks::getOccupationController()->getTalkpageManager()
- );
- $title = Title::newFromText( $dstPageName );
-
- if ( $this->hasOption( 'logfile' ) ) {
- $filename = $this->getOption( 'logfile' );
- $sourceStore = new FileImportSourceStore( $filename );
- } else {
- $sourceStore = new NullImportSourceStore;
- }
-
- if ( $this->hasOption( 'redirect' ) ) {
- if ( $this->hasOption( 'remoteapi' ) ) {
- $this->error( 'Cannot use remoteapi and redirect together', true );
- }
-
- $urlGenerator = Flow\Container::get( 'url_generator' );
- $user = Flow\Container::get( 'occupation_controller' )->getTalkpageManager();
- $redirector = new LqtRedirector( $urlGenerator, $user );
- $importer->addPostprocessor( $redirector );
- }
-
- if ( $this->hasOption( 'convertnotifications' ) ) {
- $importer->addPostprocessor( new LqtNotifications(
- Flow\Container::get( 'controller.notification' ),
- wfGetDB( DB_MASTER )
- ) );
- }
-
- if ( $this->getOption( 'verbose' ) ) {
- $logger = new MaintenanceDebugLogger( $this );
- if ( $this->getOption( 'debug' ) ) {
- $logger->setMaximumLevel( LogLevel::DEBUG );
- } else {
- $logger->setMaximumLevel( LogLevel::INFO );
- }
- $importer->setLogger( $logger );
- $api->setLogger( $logger );
- $logger->info( "Starting LQT import from $srcPageName to $dstPageName" );
- }
-
- $importer->import( $source, $title, $sourceStore );
-
- $sourceStore->save();
- }
-}
-
-$maintClass = "ConvertLqtPage";
-require_once ( RUN_MAINTENANCE_IF_MAIN );
diff --git a/maintenance/convertLqtPageFromRemoteApiForTesting.php b/maintenance/convertLqtPageFromRemoteApiForTesting.php
new file mode 100644
index 0000000000..380a531a60
--- /dev/null
+++ b/maintenance/convertLqtPageFromRemoteApiForTesting.php
@@ -0,0 +1,87 @@
+mDescription = "Converts LiquidThreads data to Flow data. Destination page is determined by ConversionStrategy";
+ $this->addOption( 'dstpage', 'Page name of the destination page on the current wiki. Defaults to same as source', false, true );
+ $this->addOption( 'srcpage', 'Page name of the source page to import from.', true, true );
+ $this->addOption( 'remoteapi', 'Remote API URL to read from', true, true );
+ $this->addOption( 'cacheremoteapidir', 'Cache remote api calls to the specified directory', true, true );
+ $this->addOption( 'logfile', 'File to read and store associations between imported items and their sources', true, true );
+ $this->addOption( 'debug', 'Include debug information to progress report' );
+ }
+
+ public function execute() {
+ $cacheDir = $this->getOption( 'cacheremoteapidir' );
+ if ( !is_dir( $cacheDir ) ) {
+ if ( !mkdir( $cacheDir ) ) {
+ throw new Flow\Exception\FlowException( 'Provided dir for caching remote api calls is not creatable.' );
+ }
+ }
+ if ( !is_writable( $cacheDir ) ) {
+ throw new Flow\Exception\FlowException( 'Provided dir for caching remote api calls is not writable.' );
+ }
+
+ $api = new RemoteApiBackend( $this->getOption( 'remoteapi' ), $cacheDir );
+
+ $importer = Flow\Container::get( 'importer' );
+ $importer->setAllowUnknownUsernames( true );
+
+ $talkPageManagerUser = \FlowHooks::getOccupationController()->getTalkpageManager();
+
+ $srcPageName = $this->getOption( 'srcpage' );
+ if ( $this->hasOption( 'dstpage' ) ) {
+ $dstPageName = $this->getOption( 'dstpage' );
+ } else {
+ $dstPageName = $srcPageName;
+ }
+
+ $dstTitle = Title::newFromText( $dstPageName );
+ $source = new LiquidThreadsApiImportSource(
+ $api,
+ $srcPageName,
+ $talkPageManagerUser
+ );
+
+ $logFilename = $this->getOption( 'logfile' );
+ $sourceStore = new FileImportSourceStore( $logFilename );
+
+ $logger = new MaintenanceDebugLogger( $this );
+ if ( $this->getOption( 'debug' ) ) {
+ $logger->setMaximumLevel( LogLevel::DEBUG );
+ } else {
+ $logger->setMaximumLevel( LogLevel::INFO );
+ }
+
+ $importer->setLogger( $logger );
+ $api->setLogger( $logger );
+
+ $logger->info( "Starting LQT conversion of page $srcPageName" );
+
+ $importer->import( $source, $dstTitle, $sourceStore );
+ }
+}
+
+$maintClass = "ConvertLqtPageFromRemoteApiForTesting";
+require_once ( RUN_MAINTENANCE_IF_MAIN );
diff --git a/maintenance/convertLqtPageOnLocalWiki.php b/maintenance/convertLqtPageOnLocalWiki.php
new file mode 100644
index 0000000000..8f25bee02a
--- /dev/null
+++ b/maintenance/convertLqtPageOnLocalWiki.php
@@ -0,0 +1,82 @@
+mDescription = "Converts LiquidThreads data to Flow data on the current wiki, using a ConversionStrategy";
+ $this->addOption( 'srcpage', 'Page name of the source page to import from.', true, true );
+ $this->addOption( 'logfile', 'File to read and store associations between imported items and their sources', true, true );
+ $this->addOption( 'debug', 'Include debug information to progress report' );
+ }
+
+ public function execute() {
+ $talkPageManagerUser = \FlowHooks::getOccupationController()->getTalkpageManager();
+
+ $api = new LocalApiBackend( $talkPageManagerUser );
+
+ $importer = Container::get( 'importer' );
+
+ $srcPageName = $this->getOption( 'srcpage' );
+
+ $logFilename = $this->getOption( 'logfile' );
+ $sourceStore = new FileImportSourceStore( $logFilename );
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ $logger = new MaintenanceDebugLogger( $this );
+ if ( $this->getOption( 'debug' ) ) {
+ $logger->setMaximumLevel( LogLevel::DEBUG );
+ } else {
+ $logger->setMaximumLevel( LogLevel::INFO );
+ }
+
+ $strategy = new LiquidThreadsApiConversionStrategy(
+ $dbw,
+ $sourceStore,
+ $api,
+ Container::get( 'url_generator' ),
+ $talkPageManagerUser,
+ Container::get( 'controller.notification' )
+ );
+
+ $importer->setLogger( $logger );
+ $api->setLogger( $logger );
+
+ $converter = new \Flow\Import\Converter(
+ $dbw,
+ $importer,
+ $logger,
+ $talkPageManagerUser,
+ $strategy
+ );
+
+ $logger->info( "Starting LQT conversion of page $srcPageName" );
+
+ $srcTitle = \Title::newFromText( $srcPageName );
+ $converter->convert( array(
+ $srcTitle,
+ ) );
+ }
+}
+
+$maintClass = "ConvertLqtPageOnLocalWiki";
+require_once ( RUN_MAINTENANCE_IF_MAIN );
diff --git a/maintenance/convertNamespaceFromWikitext.php b/maintenance/convertNamespaceFromWikitext.php
index 297e05c16c..65198dd36d 100644
--- a/maintenance/convertNamespaceFromWikitext.php
+++ b/maintenance/convertNamespaceFromWikitext.php
@@ -1,79 +1,75 @@
mDescription = "Converts a single namespace of wikitext talk pages to Flow";
$this->addArg( 'namespace', 'Name of the namespace to convert' );
- $this->addOption( 'verbose', 'Report on import progress to stdout' );
}
public function execute() {
global $wgLang, $wgParser;
$provided = $this->getArg( 0 );
$namespace = $wgLang->getNsIndex( $provided );
if ( !$namespace ) {
$this->error( "Invalid namespace provided: $provided" );
return;
}
// @todo send to prod logger?
- $logger = $this->getOption( 'verbose' )
- ? new MaintenanceDebugLogger( $this )
- : new NullLogger();
+ $logger = new MaintenanceDebugLogger( $this );
- $dbr = wfGetDB( DB_SLAVE );
+ $dbw = wfGetDB( DB_MASTER );
$converter = new \Flow\Import\Converter(
- $dbr,
+ $dbw,
Flow\Container::get( 'importer' ),
$logger,
FlowHooks::getOccupationController()->getTalkpageManager(),
new Flow\Import\Wikitext\ConversionStrategy(
$wgParser,
new Flow\Import\NullImportSourceStore()
)
);
$namespaceName = $wgLang->getNsText( $namespace );
$logger->info( "Starting conversion of $namespaceName namespace" );
// Iterate over all existing pages of the namespace.
- $it = new NamespaceIterator( $dbr, $namespace );
+ $it = new NamespaceIterator( $dbw, $namespace );
// NamespaceIterator is an IteratorAggregate. Get an Iterator
// so we can wrap that.
$it = $it->getIterator();
// if we have liquid threads filter out any pages with that enabled. They should
// be converted separately.
if ( class_exists( 'LqtDispatch' ) ) {
$it = new CallbackFilterIterator( $it, function( $title ) use ( $logger ) {
if ( LqtDispatch::isLqtPage( $title ) ) {
$logger->info( "Skipping LQT enabled page, conversion must be done with convertLqt.php or convertLqtPage.php: $title" );
return false;
} else {
return true;
}
} );
}
$converter->convert( $it );
}
}
$maintClass = "ConvertNamespaceFromWikitext";
require_once ( RUN_MAINTENANCE_IF_MAIN );
-
diff --git a/tests/phpunit/Import/ConverterTest.php b/tests/phpunit/Import/ConverterTest.php
index e5cbb07d5f..005a980591 100644
--- a/tests/phpunit/Import/ConverterTest.php
+++ b/tests/phpunit/Import/ConverterTest.php
@@ -1,101 +1,101 @@
assertInstanceOf(
'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(
- DatabaseBase $dbr = null,
+ DatabaseBase $dbw = null,
Importer $importer = null,
LoggerInterface $logger = null,
User $user = null,
IConversionStrategy $strategy = null
) {
return new Converter(
- $dbr ?: wfGetDB( DB_SLAVE ),
+ $dbw ?: wfGetDB( DB_MASTER ),
$importer ?: $this->getMockBuilder( 'Flow\Import\Importer' )
->disableOriginalConstructor()
->getMock(),
$logger ?: new NullLogger,
$user ?: User::newFromId( 1 ),
$strategy ?: $this->getMockBuilder( 'Flow\Import\IConversionStrategy' )
->disableOriginalConstructor()
->getMock()
);
}
}